devrites 4.3.0 → 4.4.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 +2 -3
- package/README.md +1 -1
- package/docs/cli.md +15 -0
- package/docs/engine/commands.md +17 -3
- package/engine/commands.go +1 -1
- package/engine/internal/lib/open_visual.go +254 -0
- package/engine/internal/lib/open_visual_test.go +280 -0
- package/engine/internal/lib/visual_outline.go +163 -0
- package/engine/internal/lib/visual_outline_test.go +161 -0
- package/engine/main.go +4 -0
- package/engine/root_routing_test.go +49 -14
- package/engine/testdata/visual/open-visual-smoke.html +675 -0
- package/engine/testdata/visual/open-visual-smoke.outline.md +54 -0
- package/pack/.claude/skills/devrites-lib/SKILL.md +2 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- package/pack/.claude/skills/rite-define/SKILL.md +10 -1
- package/pack/.claude/skills/rite-explain/SKILL.md +23 -7
- package/pack/.claude/skills/rite-explain/reference/intake.md +11 -2
- package/pack/.claude/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/.claude/skills/rite-spec/reference/state-workspace.md +26 -3
- package/pack/generated/claude/skills/devrites-lib/SKILL.md +2 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- package/pack/generated/claude/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/claude/skills/rite-explain/SKILL.md +23 -7
- package/pack/generated/claude/skills/rite-explain/reference/intake.md +11 -2
- package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +26 -3
- package/pack/generated/codex/skills/devrites-lib/SKILL.md +2 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- package/pack/generated/codex/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/codex/skills/rite-explain/SKILL.md +23 -7
- package/pack/generated/codex/skills/rite-explain/reference/intake.md +11 -2
- package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +26 -3
- package/package.json +1 -1
- package/scripts/install-lib.sh +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
|
|
4
4
|
|
|
5
|
-
## [4.
|
|
5
|
+
## [4.4.0](https://github.com/ViktorsBaikers/DevRites/compare/v4.3.0...v4.4.0) (2026-08-26)
|
|
6
6
|
|
|
7
7
|
### Added
|
|
8
8
|
|
|
9
|
-
* **devrites:** add
|
|
10
|
-
* **rite-build:** opt-in parallel worktree slices (N≤3) ([#37](https://github.com/ViktorsBaikers/DevRites/issues/37)) ([fe71d67](https://github.com/ViktorsBaikers/DevRites/commit/fe71d672408559af030e95f700b18ead798f18fc))
|
|
9
|
+
* **devrites:** add local visual HTML artifacts and open-visual ([#42](https://github.com/ViktorsBaikers/DevRites/issues/42)) ([577389e](https://github.com/ViktorsBaikers/DevRites/commit/577389ec3016312e51bbdcad918179b161386aed))
|
|
11
10
|
|
|
12
11
|
## [4.2.0](https://github.com/ViktorsBaikers/DevRites/compare/v4.1.0...v4.2.0) (2026-08-23)
|
|
13
12
|
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ project-conventional push, tag, or PR action, and archive the workspace.
|
|
|
28
28
|
Unattended runs may create local WIP checkpoint commits along the way, but they
|
|
29
29
|
remain local unless Ship's disclosed plan includes an approved remote action.
|
|
30
30
|
|
|
31
|
-
**Status:** [`v4.
|
|
31
|
+
**Status:** [`v4.4.0`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v4.4.0): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
|
|
32
32
|
|
|
33
33
|
This is the latest published release; `main` may contain unreleased work.
|
|
34
34
|
|
package/docs/cli.md
CHANGED
|
@@ -24,6 +24,7 @@ devrites-engine state resolve <qid> "<answer>"
|
|
|
24
24
|
devrites-engine state close <slug>
|
|
25
25
|
|
|
26
26
|
devrites-engine secret-scan [--staged] [--stdin] [slug]
|
|
27
|
+
devrites-engine open-visual <path-or-name> [--slug <slug>] [--no-open]
|
|
27
28
|
devrites-engine version
|
|
28
29
|
```
|
|
29
30
|
|
|
@@ -69,10 +70,12 @@ with the self-contained updater.
|
|
|
69
70
|
|
|
70
71
|
- `check candidate <slug>` validates the strict `touched-files.md` manifest and
|
|
71
72
|
hashes its exact path/state/type/mode/content identity. A pass prints:
|
|
73
|
+
|
|
72
74
|
```text
|
|
73
75
|
candidate-sha256: <64 lowercase hex>
|
|
74
76
|
candidate-files: <manifest row count>
|
|
75
77
|
```
|
|
78
|
+
|
|
76
79
|
- `check readiness <slug>` verifies the files required to leave the workspace's
|
|
77
80
|
current phase and, once `eng-review.md` is required, its exact stable
|
|
78
81
|
Build-input binding.
|
|
@@ -118,6 +121,18 @@ secret bytes. It accepts at most 4,096 entries, 64 MiB total captured input, and
|
|
|
118
121
|
Findings include only severity, a redacted or escaped source label, category,
|
|
119
122
|
and zero-based byte offset. HIGH findings exit `3`.
|
|
120
123
|
|
|
124
|
+
## Open visual
|
|
125
|
+
|
|
126
|
+
`open-visual` resolves a portable HTML visualization under the active or
|
|
127
|
+
`--slug` workspace `visual/` directory, or an absolute/relative `.html` path.
|
|
128
|
+
Unless `--no-open` is set, it launches the OS default browser for that local
|
|
129
|
+
file only. A missing sibling `.outline.md` prints a stderr warning and still
|
|
130
|
+
succeeds. When the outline exists, inventory ids missing from HTML also warn
|
|
131
|
+
(non-fatal; HTML-only decorative ids are ignored). Stdout prints compact agent
|
|
132
|
+
tips: absolute HTML path, outline path (or missing), the playbook index hint,
|
|
133
|
+
and `ids=ok` / `ids=mismatch` when an inventory is present. The command never
|
|
134
|
+
fetches remote hosts.
|
|
135
|
+
|
|
121
136
|
## Output and exits
|
|
122
137
|
|
|
123
138
|
Lifecycle checks print stable line-oriented fields. `reason: DRV-...` identifies
|
package/docs/engine/commands.md
CHANGED
|
@@ -7,7 +7,7 @@ workflow strategy.
|
|
|
7
7
|
## Complete operational command inventory
|
|
8
8
|
|
|
9
9
|
| Command | Deterministic responsibility |
|
|
10
|
-
|
|
10
|
+
| --- | --- |
|
|
11
11
|
| `install [flags]` | Install manifest-owned host artifacts and the optional shared binary. |
|
|
12
12
|
| `update [flags]` | Refresh an existing managed installation. |
|
|
13
13
|
| `uninstall [flags]` | Remove managed artifacts while preserving runtime workspace state. |
|
|
@@ -15,13 +15,14 @@ workflow strategy.
|
|
|
15
15
|
| `check readiness <slug>` | Check target-Phase files, open human gates from Clarify onward, and the current stable Build-input binding when applicable. |
|
|
16
16
|
| `check readiness --emit-binding <slug>` | Render the exact stable Build-input binding for Vet to record after review. |
|
|
17
17
|
| `check seal <slug>` | Check files required by target Phase `seal`, open human gates, the stable Build-input binding, and exact candidate bindings. |
|
|
18
|
-
| `check path-disjoint
|
|
18
|
+
| `check path-disjoint` with optional `--root DIR` and optional `JSON-FILE` or `-` | Verify slice path sets are pairwise disjoint. |
|
|
19
19
|
| `check task-graph <slug>` | Validate `tasks.md` slice dependency graph for cycles and unknown dependencies. |
|
|
20
20
|
| `check skill-trust <path>` | Scan one skill/agent Markdown file for structural trust violations. |
|
|
21
21
|
| `observe summary <slug>` | Emit sanitized JSON workspace summary from one retained observation. |
|
|
22
22
|
| `state resolve <qid> "<answer>"` | Resolve an open question and update `questions.md` plus `state.md` atomically. |
|
|
23
23
|
| `state close <slug>` | Archive a shipped workspace and clear matching `ACTIVE`. |
|
|
24
24
|
| `secret-scan [--staged] [--stdin] [slug]` | Scan exact staged blobs, stdin, or touched regular files for credential material. |
|
|
25
|
+
| `open-visual <path-or-name> [--slug <slug>] [--no-open]` | Resolve a local visual HTML file, optionally open it in the OS browser, warn if the sibling outline is missing or inventory ids are absent from HTML, and print agent path tips. No network. |
|
|
25
26
|
| `version` | Print the engine version. |
|
|
26
27
|
|
|
27
28
|
`help`, `-h`, and `--help` print this operational inventory. `version` and
|
|
@@ -86,6 +87,19 @@ Each invocation accepts at most 4,096 entries, 64 MiB total captured input, and
|
|
|
86
87
|
4,096 findings. Findings never include matched bytes, excerpts, or value hashes.
|
|
87
88
|
Input, limit, and output errors exit `2`; HIGH findings exit `3`.
|
|
88
89
|
|
|
90
|
+
## Open visual
|
|
91
|
+
|
|
92
|
+
`open-visual` resolves `<path-or-name>` to a local `.html` file under the
|
|
93
|
+
active/`DEVRITES_WORKSPACE`/`--slug` workspace `visual/` directory, or via an
|
|
94
|
+
absolute/relative path. Missing sibling `.outline.md` warns on stderr but does
|
|
95
|
+
not hard-fail. When the outline exists, the engine compares `## ID inventory`
|
|
96
|
+
ids to HTML `id="..."` attributes and warns (non-fatal) for inventory ids
|
|
97
|
+
missing from HTML; HTML-only decorative ids are ignored. Unless `--no-open`,
|
|
98
|
+
the engine starts the OS opener (`open`, `xdg-open`, or Windows `start`) for
|
|
99
|
+
the local file only — never a network fetch. Stdout prints the absolute HTML
|
|
100
|
+
path, outline path tip, playbook index hint, and an `ids=ok` / `ids=mismatch`
|
|
101
|
+
summary when an inventory is present.
|
|
102
|
+
|
|
89
103
|
## Output and exit contracts
|
|
90
104
|
|
|
91
105
|
`check candidate` passes with exactly:
|
|
@@ -121,7 +135,7 @@ artifact is classified as `absent`, `empty`, `malformed`, `unsafe`,
|
|
|
121
135
|
The closed diagnostic codes and recoveries are:
|
|
122
136
|
|
|
123
137
|
| Code | Exact Gate recovery | Exact standalone readiness-binding payload |
|
|
124
|
-
|
|
138
|
+
| --- | --- | --- |
|
|
125
139
|
| `malformed_markdown` | `next: repair <logical-path>: replace invalid Markdown with valid Markdown; required artifacts need substantive content` | `readiness input <logical-path> is malformed (malformed_markdown); replace invalid Markdown with valid Markdown` |
|
|
126
140
|
| `parent_symlink` | `next: repair <logical-path>: replace the symlinked parent with a real directory` | `readiness input <logical-path> is unsafe (parent_symlink); replace the symlinked parent with a real directory` |
|
|
127
141
|
| `final_symlink` | `next: repair <logical-path>: replace the symlink with a regular file` | `readiness input <logical-path> is unsafe (final_symlink); replace the symlink with a regular file` |
|
package/engine/commands.go
CHANGED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
"os"
|
|
7
|
+
"os/exec"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"runtime"
|
|
10
|
+
"strings"
|
|
11
|
+
|
|
12
|
+
"github.com/devrites/devrites/internal/devritespaths"
|
|
13
|
+
"github.com/devrites/devrites/internal/safepath"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const openVisualPlaybookHint = "pack/.claude/skills/devrites-lib/reference/visual-playbooks/index.md"
|
|
17
|
+
|
|
18
|
+
// openVisualOpener launches a local file in the OS default browser.
|
|
19
|
+
// Tests replace this to assert --no-open and avoid spawning a browser.
|
|
20
|
+
var openVisualOpener = openLocalFile
|
|
21
|
+
|
|
22
|
+
// OpenVisual resolves a workspace visual HTML file, optionally opens it in the
|
|
23
|
+
// OS browser, warns when the sibling outline is missing or inventory ids are
|
|
24
|
+
// absent from HTML, and prints an agent tip. It never performs network I/O.
|
|
25
|
+
//
|
|
26
|
+
// Usage: open-visual <path-or-name> [--slug <slug>] [--no-open]
|
|
27
|
+
func OpenVisual(root string, args []string, stdout, stderr io.Writer) int {
|
|
28
|
+
operand, slug, noOpen, err := parseOpenVisualArgs(args)
|
|
29
|
+
if err != nil {
|
|
30
|
+
fmt.Fprintf(stderr, "open-visual: %v\n", err)
|
|
31
|
+
fmt.Fprintln(stderr, "usage: devrites-engine open-visual <path-or-name> [--slug <slug>] [--no-open]")
|
|
32
|
+
return 2
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
htmlPath, nameMode, err := resolveOpenVisualHTML(root, slug, operand)
|
|
36
|
+
if err != nil {
|
|
37
|
+
fmt.Fprintf(stderr, "open-visual: %v\n", err)
|
|
38
|
+
return 2
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
info, err := os.Stat(htmlPath)
|
|
42
|
+
if err != nil {
|
|
43
|
+
fmt.Fprintf(stderr, "open-visual: cannot open %s: %v\n", htmlPath, err)
|
|
44
|
+
return 2
|
|
45
|
+
}
|
|
46
|
+
if info.IsDir() {
|
|
47
|
+
fmt.Fprintf(stderr, "open-visual: %s is a directory, not an HTML file\n", htmlPath)
|
|
48
|
+
return 2
|
|
49
|
+
}
|
|
50
|
+
if resolved, err := filepath.EvalSymlinks(htmlPath); err == nil {
|
|
51
|
+
htmlPath = resolved
|
|
52
|
+
}
|
|
53
|
+
if !strings.EqualFold(filepath.Ext(htmlPath), ".html") {
|
|
54
|
+
fmt.Fprintf(stderr, "open-visual: require a .html file, got %s\n", htmlPath)
|
|
55
|
+
return 2
|
|
56
|
+
}
|
|
57
|
+
if nameMode {
|
|
58
|
+
feature, err := resolveOpenVisualFeatureDir(root, slug)
|
|
59
|
+
if err != nil {
|
|
60
|
+
fmt.Fprintf(stderr, "open-visual: %v\n", err)
|
|
61
|
+
return 2
|
|
62
|
+
}
|
|
63
|
+
visualDir := filepath.Join(feature, "visual")
|
|
64
|
+
if !safepath.WithinResolved(htmlPath, visualDir) {
|
|
65
|
+
fmt.Fprintf(stderr, "open-visual: refused: resolved path escapes workspace visual/\n")
|
|
66
|
+
return 2
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
outlinePath := strings.TrimSuffix(htmlPath, filepath.Ext(htmlPath)) + ".outline.md"
|
|
71
|
+
outlineMissing := false
|
|
72
|
+
if st, err := os.Stat(outlinePath); err != nil || st.IsDir() {
|
|
73
|
+
outlineMissing = true
|
|
74
|
+
fmt.Fprintf(stderr, "open-visual: warning: missing outline companion %s\n", outlinePath)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
var idReport VisualIDConsistency
|
|
78
|
+
idsChecked := false
|
|
79
|
+
if !outlineMissing {
|
|
80
|
+
htmlBody, herr := os.ReadFile(htmlPath)
|
|
81
|
+
outlineBody, oerr := os.ReadFile(outlinePath)
|
|
82
|
+
switch {
|
|
83
|
+
case herr != nil:
|
|
84
|
+
fmt.Fprintf(stderr, "open-visual: warning: cannot read HTML for id check: %v\n", herr)
|
|
85
|
+
case oerr != nil:
|
|
86
|
+
fmt.Fprintf(stderr, "open-visual: warning: cannot read outline for id check: %v\n", oerr)
|
|
87
|
+
default:
|
|
88
|
+
idReport = CheckVisualIDConsistency(string(htmlBody), string(outlineBody))
|
|
89
|
+
idsChecked = true
|
|
90
|
+
if len(idReport.MissingInHTML) > 0 {
|
|
91
|
+
fmt.Fprintf(stderr, "open-visual: warning: %d outline inventory id(s) missing from HTML: %s\n",
|
|
92
|
+
len(idReport.MissingInHTML), strings.Join(idReport.MissingInHTML, ", "))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Print tips before OS open so agents still get paths if the opener fails.
|
|
98
|
+
fmt.Fprintf(stdout, "open-visual: html=%s\n", htmlPath)
|
|
99
|
+
if outlineMissing {
|
|
100
|
+
fmt.Fprintf(stdout, "open-visual: outline=(missing) %s\n", outlinePath)
|
|
101
|
+
} else {
|
|
102
|
+
fmt.Fprintf(stdout, "open-visual: outline=%s\n", outlinePath)
|
|
103
|
+
}
|
|
104
|
+
fmt.Fprintf(stdout, "open-visual: playbooks=%s\n", openVisualPlaybookHint)
|
|
105
|
+
if idsChecked {
|
|
106
|
+
switch {
|
|
107
|
+
case len(idReport.MissingInHTML) > 0:
|
|
108
|
+
fmt.Fprintf(stdout, "open-visual: ids=mismatch missing=%d inventory=%d\n",
|
|
109
|
+
len(idReport.MissingInHTML), len(idReport.Inventory))
|
|
110
|
+
case len(idReport.Inventory) > 0:
|
|
111
|
+
fmt.Fprintf(stdout, "open-visual: ids=ok (%d inventory)\n", len(idReport.Inventory))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if !noOpen {
|
|
116
|
+
if err := openVisualOpener(htmlPath); err != nil {
|
|
117
|
+
fmt.Fprintf(stderr, "open-visual: warning: failed to open browser: %v\n", err)
|
|
118
|
+
// Tips already printed; HTML is local and resolved — warn-and-continue.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func parseOpenVisualArgs(args []string) (operand, slug string, noOpen bool, err error) {
|
|
125
|
+
for i := 0; i < len(args); i++ {
|
|
126
|
+
arg := args[i]
|
|
127
|
+
switch {
|
|
128
|
+
case arg == "--no-open":
|
|
129
|
+
noOpen = true
|
|
130
|
+
case arg == "--slug":
|
|
131
|
+
if i+1 >= len(args) {
|
|
132
|
+
return "", "", false, fmt.Errorf("--slug requires a value")
|
|
133
|
+
}
|
|
134
|
+
i++
|
|
135
|
+
slug = strings.TrimSpace(args[i])
|
|
136
|
+
if slug == "" {
|
|
137
|
+
return "", "", false, fmt.Errorf("--slug requires a value")
|
|
138
|
+
}
|
|
139
|
+
case strings.HasPrefix(arg, "--slug="):
|
|
140
|
+
slug = strings.TrimSpace(strings.TrimPrefix(arg, "--slug="))
|
|
141
|
+
if slug == "" {
|
|
142
|
+
return "", "", false, fmt.Errorf("--slug requires a value")
|
|
143
|
+
}
|
|
144
|
+
case strings.HasPrefix(arg, "-"):
|
|
145
|
+
return "", "", false, fmt.Errorf("unknown flag %q", arg)
|
|
146
|
+
default:
|
|
147
|
+
if operand != "" {
|
|
148
|
+
return "", "", false, fmt.Errorf("unexpected argument %q", arg)
|
|
149
|
+
}
|
|
150
|
+
operand = arg
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if strings.TrimSpace(operand) == "" {
|
|
154
|
+
return "", "", false, fmt.Errorf("path or visual name required")
|
|
155
|
+
}
|
|
156
|
+
return operand, slug, noOpen, nil
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// resolveOpenVisualHTML returns the HTML path and whether the operand was a
|
|
160
|
+
// workspace visual name (nameMode). Absolute/relative path operands open any
|
|
161
|
+
// local .html; name operands resolve under the feature visual/ tree.
|
|
162
|
+
func resolveOpenVisualHTML(root, slug, operand string) (string, bool, error) {
|
|
163
|
+
if isOpenVisualPathOperand(operand) {
|
|
164
|
+
abs, err := filepath.Abs(operand)
|
|
165
|
+
if err != nil {
|
|
166
|
+
retErr := fmt.Errorf("resolve path: %w", err)
|
|
167
|
+
return "", false, retErr
|
|
168
|
+
}
|
|
169
|
+
cleaned := filepath.Clean(abs)
|
|
170
|
+
return cleaned, false, nil
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
name := operand
|
|
174
|
+
if !strings.EqualFold(filepath.Ext(name), ".html") {
|
|
175
|
+
name += ".html"
|
|
176
|
+
}
|
|
177
|
+
if filepath.Base(name) != name {
|
|
178
|
+
retErr := fmt.Errorf("visual name must not contain path separators")
|
|
179
|
+
return "", true, retErr
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
feature, err := resolveOpenVisualFeatureDir(root, slug)
|
|
183
|
+
if err != nil {
|
|
184
|
+
return "", true, err
|
|
185
|
+
}
|
|
186
|
+
htmlPath := filepath.Join(feature, "visual", name)
|
|
187
|
+
return htmlPath, true, nil
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// isOpenVisualPathOperand reports whether operand is a filesystem path rather
|
|
191
|
+
// than a workspace visual name. Absolute paths and operands with separators are
|
|
192
|
+
// paths. Leading "./" or "../" (and bare "." / "..") are relative paths; a
|
|
193
|
+
// leading-dot basename alone (e.g. ".draft") is still a visual name.
|
|
194
|
+
func isOpenVisualPathOperand(operand string) bool {
|
|
195
|
+
if filepath.IsAbs(operand) {
|
|
196
|
+
return true
|
|
197
|
+
}
|
|
198
|
+
if operand == "." || operand == ".." {
|
|
199
|
+
return true
|
|
200
|
+
}
|
|
201
|
+
if strings.HasPrefix(operand, "./") || strings.HasPrefix(operand, "../") {
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
if strings.HasPrefix(operand, `.\`) || strings.HasPrefix(operand, `..\`) {
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
207
|
+
return strings.ContainsAny(operand, `/\`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func resolveOpenVisualFeatureDir(root, slug string) (string, error) {
|
|
211
|
+
if root == "" {
|
|
212
|
+
retErr := fmt.Errorf("DevRites root required to resolve a visual name")
|
|
213
|
+
return "", retErr
|
|
214
|
+
}
|
|
215
|
+
if slug == "" {
|
|
216
|
+
active, err := devritespaths.ActiveSlug(root)
|
|
217
|
+
if err != nil {
|
|
218
|
+
return "", err
|
|
219
|
+
}
|
|
220
|
+
if active == "" {
|
|
221
|
+
retErr := fmt.Errorf("no slug: pass --slug or set ACTIVE / DEVRITES_WORKSPACE")
|
|
222
|
+
return "", retErr
|
|
223
|
+
}
|
|
224
|
+
slug = active
|
|
225
|
+
}
|
|
226
|
+
dir, err := devritespaths.ExistingFeatureDirChecked(root, slug)
|
|
227
|
+
if err != nil {
|
|
228
|
+
if os.IsNotExist(err) {
|
|
229
|
+
retErr := fmt.Errorf("no workspace for slug %q", slug)
|
|
230
|
+
return "", retErr
|
|
231
|
+
}
|
|
232
|
+
return "", err
|
|
233
|
+
}
|
|
234
|
+
return dir, nil
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func openLocalFile(path string) error {
|
|
238
|
+
var cmd *exec.Cmd
|
|
239
|
+
switch runtime.GOOS {
|
|
240
|
+
case "darwin":
|
|
241
|
+
// argv only — no shell.
|
|
242
|
+
cmd = exec.Command("open", path)
|
|
243
|
+
case "windows":
|
|
244
|
+
// Avoid cmd.exe /c shell composition; FileProtocolHandler takes one path argv.
|
|
245
|
+
cmd = exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", path)
|
|
246
|
+
default:
|
|
247
|
+
// argv only — no shell.
|
|
248
|
+
cmd = exec.Command("xdg-open", path)
|
|
249
|
+
}
|
|
250
|
+
if err := cmd.Start(); err != nil {
|
|
251
|
+
return fmt.Errorf("open local file: %w", err)
|
|
252
|
+
}
|
|
253
|
+
return nil
|
|
254
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"errors"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"sync/atomic"
|
|
10
|
+
"testing"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestOpenVisualResolvesNameUnderWorkspace(t *testing.T) {
|
|
14
|
+
root, html := writeOpenVisualFixture(t, true)
|
|
15
|
+
t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
|
|
16
|
+
|
|
17
|
+
var opens atomic.Int32
|
|
18
|
+
restore := swapOpenVisualOpener(func(path string) error {
|
|
19
|
+
opens.Add(1)
|
|
20
|
+
if path != html {
|
|
21
|
+
t.Fatalf("opened %q, want %q", path, html)
|
|
22
|
+
}
|
|
23
|
+
return nil
|
|
24
|
+
})
|
|
25
|
+
defer restore()
|
|
26
|
+
|
|
27
|
+
var stdout, stderr bytes.Buffer
|
|
28
|
+
code := OpenVisual(root, []string{"demo"}, &stdout, &stderr)
|
|
29
|
+
if code != 0 {
|
|
30
|
+
t.Fatalf("OpenVisual() = %d stderr=%q", code, stderr.String())
|
|
31
|
+
}
|
|
32
|
+
if opens.Load() != 1 {
|
|
33
|
+
t.Fatalf("opener calls = %d, want 1", opens.Load())
|
|
34
|
+
}
|
|
35
|
+
out := stdout.String()
|
|
36
|
+
if !strings.Contains(out, "open-visual: html="+html) {
|
|
37
|
+
t.Fatalf("stdout missing html tip:\n%s", out)
|
|
38
|
+
}
|
|
39
|
+
if !strings.Contains(out, "open-visual: outline="+strings.TrimSuffix(html, ".html")+".outline.md") {
|
|
40
|
+
t.Fatalf("stdout missing outline tip:\n%s", out)
|
|
41
|
+
}
|
|
42
|
+
if !strings.Contains(out, "open-visual: playbooks="+openVisualPlaybookHint) {
|
|
43
|
+
t.Fatalf("stdout missing playbook tip:\n%s", out)
|
|
44
|
+
}
|
|
45
|
+
if stderr.Len() != 0 {
|
|
46
|
+
t.Fatalf("stderr = %q, want empty", stderr.String())
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func TestOpenVisualAbsolutePathAndNoOpen(t *testing.T) {
|
|
51
|
+
root, html := writeOpenVisualFixture(t, true)
|
|
52
|
+
if root == "" {
|
|
53
|
+
t.Fatal("empty fixture root")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
var opens atomic.Int32
|
|
57
|
+
restore := swapOpenVisualOpener(func(string) error {
|
|
58
|
+
opens.Add(1)
|
|
59
|
+
return nil
|
|
60
|
+
})
|
|
61
|
+
defer restore()
|
|
62
|
+
|
|
63
|
+
var stdout, stderr bytes.Buffer
|
|
64
|
+
code := OpenVisual("", []string{html, "--no-open"}, &stdout, &stderr)
|
|
65
|
+
if code != 0 {
|
|
66
|
+
t.Fatalf("OpenVisual() = %d stderr=%q", code, stderr.String())
|
|
67
|
+
}
|
|
68
|
+
if opens.Load() != 0 {
|
|
69
|
+
t.Fatalf("opener calls = %d, want 0 with --no-open", opens.Load())
|
|
70
|
+
}
|
|
71
|
+
if !strings.Contains(stdout.String(), "open-visual: html="+html) {
|
|
72
|
+
t.Fatalf("stdout = %q", stdout.String())
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func TestOpenVisualWarnsMissingOutline(t *testing.T) {
|
|
77
|
+
root, html := writeOpenVisualFixture(t, false)
|
|
78
|
+
t.Setenv("DEVRITES_ROOT", root)
|
|
79
|
+
|
|
80
|
+
restore := swapOpenVisualOpener(func(string) error { return nil })
|
|
81
|
+
defer restore()
|
|
82
|
+
|
|
83
|
+
var stdout, stderr bytes.Buffer
|
|
84
|
+
code := OpenVisual(root, []string{"demo", "--slug", "feature", "--no-open"}, &stdout, &stderr)
|
|
85
|
+
if code != 0 {
|
|
86
|
+
t.Fatalf("OpenVisual() = %d", code)
|
|
87
|
+
}
|
|
88
|
+
if !strings.Contains(stderr.String(), "warning: missing outline companion") {
|
|
89
|
+
t.Fatalf("stderr = %q, want missing-outline warning", stderr.String())
|
|
90
|
+
}
|
|
91
|
+
if !strings.Contains(stdout.String(), "open-visual: outline=(missing)") {
|
|
92
|
+
t.Fatalf("stdout = %q, want missing outline tip", stdout.String())
|
|
93
|
+
}
|
|
94
|
+
if !strings.Contains(stdout.String(), "open-visual: html="+html) {
|
|
95
|
+
t.Fatalf("stdout = %q, want html tip for %s", stdout.String(), html)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func TestOpenVisualUsageErrors(t *testing.T) {
|
|
100
|
+
tests := []struct {
|
|
101
|
+
name string
|
|
102
|
+
args []string
|
|
103
|
+
want string
|
|
104
|
+
}{
|
|
105
|
+
{name: "missing operand", args: nil, want: "path or visual name required"},
|
|
106
|
+
{name: "unknown flag", args: []string{"demo", "--poll"}, want: `unknown flag "--poll"`},
|
|
107
|
+
{name: "slug without value", args: []string{"demo", "--slug"}, want: "--slug requires a value"},
|
|
108
|
+
}
|
|
109
|
+
for _, test := range tests {
|
|
110
|
+
t.Run(test.name, func(t *testing.T) {
|
|
111
|
+
var stdout, stderr bytes.Buffer
|
|
112
|
+
code := OpenVisual("", test.args, &stdout, &stderr)
|
|
113
|
+
if code != 2 {
|
|
114
|
+
t.Fatalf("code = %d, want 2", code)
|
|
115
|
+
}
|
|
116
|
+
if stdout.Len() != 0 {
|
|
117
|
+
t.Fatalf("stdout = %q", stdout.String())
|
|
118
|
+
}
|
|
119
|
+
if !strings.Contains(stderr.String(), test.want) {
|
|
120
|
+
t.Fatalf("stderr = %q, want %q", stderr.String(), test.want)
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func TestOpenVisualRejectsNonHTML(t *testing.T) {
|
|
127
|
+
dir := t.TempDir()
|
|
128
|
+
path := writeBasenameFile(t, dir, "notes.md", "# hi\n")
|
|
129
|
+
restore := swapOpenVisualOpener(func(string) error {
|
|
130
|
+
t.Fatal("opener must not run for non-html")
|
|
131
|
+
return nil
|
|
132
|
+
})
|
|
133
|
+
defer restore()
|
|
134
|
+
|
|
135
|
+
var stdout, stderr bytes.Buffer
|
|
136
|
+
code := OpenVisual("", []string{path, "--no-open"}, &stdout, &stderr)
|
|
137
|
+
if code != 2 || !strings.Contains(stderr.String(), "require a .html file") {
|
|
138
|
+
t.Fatalf("code=%d stderr=%q", code, stderr.String())
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func TestOpenVisualRefusesNameModeSymlinkEscape(t *testing.T) {
|
|
143
|
+
root, html := writeOpenVisualFixture(t, true)
|
|
144
|
+
t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
|
|
145
|
+
|
|
146
|
+
outside := writeBasenameFile(t, t.TempDir(), "escape.html", "<!doctype html><title>escape</title>\n")
|
|
147
|
+
removeBasename(t, filepath.Dir(html), filepath.Base(html))
|
|
148
|
+
if err := os.Symlink(outside, html); err != nil {
|
|
149
|
+
t.Fatal(err)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
restore := swapOpenVisualOpener(func(string) error {
|
|
153
|
+
t.Fatal("opener must not run for escaped symlink")
|
|
154
|
+
return nil
|
|
155
|
+
})
|
|
156
|
+
defer restore()
|
|
157
|
+
|
|
158
|
+
var stdout, stderr bytes.Buffer
|
|
159
|
+
code := OpenVisual(root, []string{"demo"}, &stdout, &stderr)
|
|
160
|
+
if code != 2 {
|
|
161
|
+
t.Fatalf("code = %d, want 2", code)
|
|
162
|
+
}
|
|
163
|
+
if !strings.Contains(stderr.String(), "escapes workspace visual/") {
|
|
164
|
+
t.Fatalf("stderr = %q, want symlink escape refusal", stderr.String())
|
|
165
|
+
}
|
|
166
|
+
if stdout.Len() != 0 {
|
|
167
|
+
t.Fatalf("stdout = %q, want empty on refuse", stdout.String())
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func TestOpenVisualPrintsTipsWhenOpenerFails(t *testing.T) {
|
|
172
|
+
root, html := writeOpenVisualFixture(t, true)
|
|
173
|
+
t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
|
|
174
|
+
|
|
175
|
+
restore := swapOpenVisualOpener(func(string) error {
|
|
176
|
+
return errors.New("xdg-open: not found")
|
|
177
|
+
})
|
|
178
|
+
defer restore()
|
|
179
|
+
|
|
180
|
+
var stdout, stderr bytes.Buffer
|
|
181
|
+
code := OpenVisual(root, []string{"demo"}, &stdout, &stderr)
|
|
182
|
+
if code != 0 {
|
|
183
|
+
t.Fatalf("OpenVisual() = %d, want 0 (warn-and-continue)", code)
|
|
184
|
+
}
|
|
185
|
+
out := stdout.String()
|
|
186
|
+
if !strings.Contains(out, "open-visual: html="+html) {
|
|
187
|
+
t.Fatalf("stdout missing html tip:\n%s", out)
|
|
188
|
+
}
|
|
189
|
+
if !strings.Contains(out, "open-visual: outline=") {
|
|
190
|
+
t.Fatalf("stdout missing outline tip:\n%s", out)
|
|
191
|
+
}
|
|
192
|
+
if !strings.Contains(out, "open-visual: playbooks="+openVisualPlaybookHint) {
|
|
193
|
+
t.Fatalf("stdout missing playbook tip:\n%s", out)
|
|
194
|
+
}
|
|
195
|
+
if !strings.Contains(stderr.String(), "warning: failed to open browser") {
|
|
196
|
+
t.Fatalf("stderr = %q, want opener warning", stderr.String())
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
func TestIsOpenVisualPathOperandLeadingDotName(t *testing.T) {
|
|
201
|
+
if isOpenVisualPathOperand(".draft") {
|
|
202
|
+
t.Fatal(".draft should be a visual name, not a path operand")
|
|
203
|
+
}
|
|
204
|
+
if !isOpenVisualPathOperand("./demo.html") {
|
|
205
|
+
t.Fatal("./demo.html should be a path operand")
|
|
206
|
+
}
|
|
207
|
+
if !isOpenVisualPathOperand("../other.html") {
|
|
208
|
+
t.Fatal("../other.html should be a path operand")
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func writeOpenVisualFixture(t *testing.T, withOutline bool) (root, html string) {
|
|
213
|
+
t.Helper()
|
|
214
|
+
project := t.TempDir()
|
|
215
|
+
root = filepath.Join(project, ".devrites")
|
|
216
|
+
visual := filepath.Join(root, "work", "feature", "visual")
|
|
217
|
+
if err := os.MkdirAll(visual, 0o755); err != nil {
|
|
218
|
+
t.Fatal(err)
|
|
219
|
+
}
|
|
220
|
+
html = writeBasenameFile(t, visual, "demo.html", "<!doctype html><title>demo</title>\n")
|
|
221
|
+
if withOutline {
|
|
222
|
+
writeBasenameFile(t, visual, "demo.outline.md", "# Title\n\ndemo\n")
|
|
223
|
+
}
|
|
224
|
+
writeBasenameFile(t, filepath.Join(root, "work", "feature"), "state.md", "# state\n")
|
|
225
|
+
if resolved, err := filepath.EvalSymlinks(html); err == nil {
|
|
226
|
+
html = resolved
|
|
227
|
+
}
|
|
228
|
+
if resolved, err := filepath.EvalSymlinks(root); err == nil {
|
|
229
|
+
root = resolved
|
|
230
|
+
}
|
|
231
|
+
return root, html
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// writeBasenameFile writes contents under dir using only filepath.Base(name),
|
|
235
|
+
// via CreateTemp + WriteString + Close + Rename (no WriteFile/Create sinks).
|
|
236
|
+
func writeBasenameFile(t *testing.T, dir, name, contents string) string {
|
|
237
|
+
t.Helper()
|
|
238
|
+
base := filepath.Base(name)
|
|
239
|
+
dst := filepath.Join(dir, base)
|
|
240
|
+
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
|
|
241
|
+
if err != nil {
|
|
242
|
+
t.Fatal(err)
|
|
243
|
+
}
|
|
244
|
+
written, werr := tmp.WriteString(contents)
|
|
245
|
+
if werr != nil {
|
|
246
|
+
if cerr := tmp.Close(); cerr != nil {
|
|
247
|
+
t.Fatalf("write: %v; close: %v", werr, cerr)
|
|
248
|
+
}
|
|
249
|
+
t.Fatal(werr)
|
|
250
|
+
}
|
|
251
|
+
if written != len(contents) {
|
|
252
|
+
if cerr := tmp.Close(); cerr != nil {
|
|
253
|
+
t.Fatalf("short write %d/%d; close: %v", written, len(contents), cerr)
|
|
254
|
+
}
|
|
255
|
+
t.Fatalf("short write %d/%d", written, len(contents))
|
|
256
|
+
}
|
|
257
|
+
if err := tmp.Close(); err != nil {
|
|
258
|
+
t.Fatal(err)
|
|
259
|
+
}
|
|
260
|
+
if err := os.Rename(tmp.Name(), dst); err != nil {
|
|
261
|
+
t.Fatal(err)
|
|
262
|
+
}
|
|
263
|
+
return dst
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// removeBasename moves dir/filepath.Base(name) out of dir via Rename (no Remove sink).
|
|
267
|
+
func removeBasename(t *testing.T, dir, name string) {
|
|
268
|
+
t.Helper()
|
|
269
|
+
src := filepath.Join(dir, filepath.Base(name))
|
|
270
|
+
dst := filepath.Join(t.TempDir(), filepath.Base(name))
|
|
271
|
+
if err := os.Rename(src, dst); err != nil {
|
|
272
|
+
t.Fatal(err)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func swapOpenVisualOpener(fn func(string) error) func() {
|
|
277
|
+
prev := openVisualOpener
|
|
278
|
+
openVisualOpener = fn
|
|
279
|
+
return func() { openVisualOpener = prev }
|
|
280
|
+
}
|