procoder-cli 0.1.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/AGENTS.md +122 -0
- package/CONTRIBUTORS.md +79 -0
- package/Makefile +82 -0
- package/README.md +125 -0
- package/bin/procoder.js +28 -0
- package/cmd/procoder/main.go +15 -0
- package/cmd/procoder-return/main.go +67 -0
- package/cmd/procoder-return/main_test.go +77 -0
- package/go.mod +3 -0
- package/internal/app/app.go +224 -0
- package/internal/app/app_test.go +253 -0
- package/internal/app/roundtrip_test.go +215 -0
- package/internal/apply/apply.go +1069 -0
- package/internal/apply/apply_test.go +794 -0
- package/internal/errs/errors.go +114 -0
- package/internal/exchange/exchange_test.go +165 -0
- package/internal/exchange/id.go +66 -0
- package/internal/exchange/json.go +64 -0
- package/internal/exchange/types.go +55 -0
- package/internal/gitx/gitx.go +105 -0
- package/internal/gitx/gitx_test.go +51 -0
- package/internal/output/errors.go +49 -0
- package/internal/output/errors_test.go +41 -0
- package/internal/prepare/prepare.go +788 -0
- package/internal/prepare/prepare_test.go +416 -0
- package/internal/returnpkg/returnpkg.go +589 -0
- package/internal/returnpkg/returnpkg_test.go +489 -0
- package/internal/testutil/gitrepo/repo.go +113 -0
- package/package.json +47 -0
- package/scripts/postinstall.js +263 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
"strings"
|
|
7
|
+
|
|
8
|
+
"github.com/amxv/procoder/internal/apply"
|
|
9
|
+
"github.com/amxv/procoder/internal/errs"
|
|
10
|
+
"github.com/amxv/procoder/internal/prepare"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
const commandName = "procoder"
|
|
14
|
+
|
|
15
|
+
var version = "dev"
|
|
16
|
+
var runPrepare = prepare.Run
|
|
17
|
+
var runApplyDryRun = apply.RunDryRun
|
|
18
|
+
var runApply = apply.Run
|
|
19
|
+
|
|
20
|
+
func Run(args []string, stdout, stderr io.Writer) error {
|
|
21
|
+
_ = stderr
|
|
22
|
+
|
|
23
|
+
if len(args) == 0 || isHelpArg(args[0]) {
|
|
24
|
+
printRootHelp(stdout)
|
|
25
|
+
return nil
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
switch args[0] {
|
|
29
|
+
case "version":
|
|
30
|
+
_, _ = fmt.Fprintf(stdout, "%s %s\n", commandName, version)
|
|
31
|
+
return nil
|
|
32
|
+
case "prepare":
|
|
33
|
+
if len(args) > 1 && isHelpArg(args[1]) {
|
|
34
|
+
printPrepareHelp(stdout)
|
|
35
|
+
return nil
|
|
36
|
+
}
|
|
37
|
+
if len(args) > 1 {
|
|
38
|
+
return errs.New(
|
|
39
|
+
errs.CodeUnknownCommand,
|
|
40
|
+
fmt.Sprintf("unknown argument %q for `procoder prepare`", args[1]),
|
|
41
|
+
errs.WithHint("run `procoder prepare --help`"),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
result, err := runPrepare(prepare.Options{ToolVersion: version})
|
|
45
|
+
if err != nil {
|
|
46
|
+
return err
|
|
47
|
+
}
|
|
48
|
+
writeLines(stdout, prepare.FormatSuccess(result))
|
|
49
|
+
return nil
|
|
50
|
+
case "apply":
|
|
51
|
+
if len(args) > 1 && isHelpArg(args[1]) {
|
|
52
|
+
printApplyHelp(stdout)
|
|
53
|
+
return nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
parsed, err := parseApplyArgs(args[1:])
|
|
57
|
+
if err != nil {
|
|
58
|
+
return err
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if parsed.DryRun {
|
|
62
|
+
plan, err := runApplyDryRun(apply.Options{
|
|
63
|
+
ReturnPackagePath: parsed.ReturnPackagePath,
|
|
64
|
+
Namespace: parsed.Namespace,
|
|
65
|
+
})
|
|
66
|
+
if err != nil {
|
|
67
|
+
return err
|
|
68
|
+
}
|
|
69
|
+
writeLines(stdout, apply.FormatDryRun(plan))
|
|
70
|
+
return nil
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
result, err := runApply(apply.Options{
|
|
74
|
+
ReturnPackagePath: parsed.ReturnPackagePath,
|
|
75
|
+
Namespace: parsed.Namespace,
|
|
76
|
+
Checkout: parsed.Checkout,
|
|
77
|
+
})
|
|
78
|
+
if err != nil {
|
|
79
|
+
return err
|
|
80
|
+
}
|
|
81
|
+
writeLines(stdout, apply.FormatSuccess(result))
|
|
82
|
+
return nil
|
|
83
|
+
default:
|
|
84
|
+
return errs.New(
|
|
85
|
+
errs.CodeUnknownCommand,
|
|
86
|
+
fmt.Sprintf("unknown command %q", args[0]),
|
|
87
|
+
errs.WithHint(fmt.Sprintf("run `%s --help`", commandName)),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type applyArgs struct {
|
|
93
|
+
ReturnPackagePath string
|
|
94
|
+
DryRun bool
|
|
95
|
+
Namespace string
|
|
96
|
+
Checkout bool
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func parseApplyArgs(args []string) (applyArgs, error) {
|
|
100
|
+
parsed := applyArgs{}
|
|
101
|
+
for i := 0; i < len(args); i++ {
|
|
102
|
+
arg := args[i]
|
|
103
|
+
switch {
|
|
104
|
+
case arg == "--dry-run":
|
|
105
|
+
parsed.DryRun = true
|
|
106
|
+
case arg == "--checkout":
|
|
107
|
+
parsed.Checkout = true
|
|
108
|
+
case arg == "--namespace":
|
|
109
|
+
if i+1 >= len(args) {
|
|
110
|
+
return applyArgs{}, errs.New(
|
|
111
|
+
errs.CodeUnknownCommand,
|
|
112
|
+
"missing value for `--namespace`",
|
|
113
|
+
errs.WithHint("run `procoder apply --help`"),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
i++
|
|
117
|
+
value := strings.TrimSpace(args[i])
|
|
118
|
+
if value == "" {
|
|
119
|
+
return applyArgs{}, errs.New(
|
|
120
|
+
errs.CodeUnknownCommand,
|
|
121
|
+
"namespace prefix for `--namespace` must not be empty",
|
|
122
|
+
errs.WithHint("run `procoder apply --help`"),
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
parsed.Namespace = value
|
|
126
|
+
case strings.HasPrefix(arg, "--namespace="):
|
|
127
|
+
value := strings.TrimSpace(strings.TrimPrefix(arg, "--namespace="))
|
|
128
|
+
if value == "" {
|
|
129
|
+
return applyArgs{}, errs.New(
|
|
130
|
+
errs.CodeUnknownCommand,
|
|
131
|
+
"namespace prefix for `--namespace` must not be empty",
|
|
132
|
+
errs.WithHint("run `procoder apply --help`"),
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
parsed.Namespace = value
|
|
136
|
+
case strings.HasPrefix(arg, "-"):
|
|
137
|
+
return applyArgs{}, unknownApplyArgument(arg)
|
|
138
|
+
default:
|
|
139
|
+
if parsed.ReturnPackagePath == "" {
|
|
140
|
+
parsed.ReturnPackagePath = arg
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
return applyArgs{}, unknownApplyArgument(arg)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if strings.TrimSpace(parsed.ReturnPackagePath) == "" {
|
|
148
|
+
return applyArgs{}, errs.New(
|
|
149
|
+
errs.CodeUnknownCommand,
|
|
150
|
+
"missing return package path for `procoder apply`",
|
|
151
|
+
errs.WithHint("run `procoder apply --help`"),
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return parsed, nil
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func unknownApplyArgument(arg string) error {
|
|
159
|
+
return errs.New(
|
|
160
|
+
errs.CodeUnknownCommand,
|
|
161
|
+
fmt.Sprintf("unknown argument %q for `procoder apply`", arg),
|
|
162
|
+
errs.WithHint("run `procoder apply --help`"),
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func isHelpArg(v string) bool {
|
|
167
|
+
switch v {
|
|
168
|
+
case "-h", "--help", "help":
|
|
169
|
+
return true
|
|
170
|
+
default:
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func printRootHelp(w io.Writer) {
|
|
176
|
+
writeLines(w,
|
|
177
|
+
"procoder",
|
|
178
|
+
"",
|
|
179
|
+
"Usage:",
|
|
180
|
+
" procoder <command> [arguments]",
|
|
181
|
+
"",
|
|
182
|
+
"Commands:",
|
|
183
|
+
" prepare create a task package",
|
|
184
|
+
" apply <return-package.zip> apply a return package",
|
|
185
|
+
" version print CLI version",
|
|
186
|
+
"",
|
|
187
|
+
"Examples:",
|
|
188
|
+
" procoder prepare",
|
|
189
|
+
" procoder apply procoder-return-<exchange-id>.zip --dry-run",
|
|
190
|
+
" procoder version",
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func printPrepareHelp(w io.Writer) {
|
|
195
|
+
writeLines(w,
|
|
196
|
+
"procoder prepare - create a task package",
|
|
197
|
+
"",
|
|
198
|
+
"Usage:",
|
|
199
|
+
" procoder prepare",
|
|
200
|
+
"",
|
|
201
|
+
"Examples:",
|
|
202
|
+
" procoder prepare",
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
func printApplyHelp(w io.Writer) {
|
|
207
|
+
writeLines(w,
|
|
208
|
+
"procoder apply - apply a return package",
|
|
209
|
+
"",
|
|
210
|
+
"Usage:",
|
|
211
|
+
" procoder apply <return-package.zip> [--dry-run] [--namespace <prefix>] [--checkout]",
|
|
212
|
+
"",
|
|
213
|
+
"Examples:",
|
|
214
|
+
" procoder apply procoder-return-<exchange-id>.zip",
|
|
215
|
+
" procoder apply procoder-return-<exchange-id>.zip --dry-run",
|
|
216
|
+
" procoder apply procoder-return-<exchange-id>.zip --dry-run --namespace procoder-import",
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
func writeLines(w io.Writer, lines ...string) {
|
|
221
|
+
for _, line := range lines {
|
|
222
|
+
_, _ = fmt.Fprintln(w, line)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/amxv/procoder/internal/apply"
|
|
9
|
+
"github.com/amxv/procoder/internal/errs"
|
|
10
|
+
"github.com/amxv/procoder/internal/prepare"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestRunRootHelp(t *testing.T) {
|
|
14
|
+
var out bytes.Buffer
|
|
15
|
+
var errBuf bytes.Buffer
|
|
16
|
+
|
|
17
|
+
err := Run([]string{"--help"}, &out, &errBuf)
|
|
18
|
+
if err != nil {
|
|
19
|
+
t.Fatalf("Run returned error: %v", err)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if !strings.Contains(out.String(), "Usage:") {
|
|
23
|
+
t.Fatalf("expected help output, got: %q", out.String())
|
|
24
|
+
}
|
|
25
|
+
if !strings.Contains(out.String(), "prepare") {
|
|
26
|
+
t.Fatalf("expected prepare command in help output, got: %q", out.String())
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func TestRunVersion(t *testing.T) {
|
|
31
|
+
var out bytes.Buffer
|
|
32
|
+
var errBuf bytes.Buffer
|
|
33
|
+
|
|
34
|
+
err := Run([]string{"version"}, &out, &errBuf)
|
|
35
|
+
if err != nil {
|
|
36
|
+
t.Fatalf("Run returned error: %v", err)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if !strings.Contains(out.String(), "procoder ") {
|
|
40
|
+
t.Fatalf("unexpected output: %q", out.String())
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func TestRunApplyHelpTerminology(t *testing.T) {
|
|
45
|
+
var out bytes.Buffer
|
|
46
|
+
var errBuf bytes.Buffer
|
|
47
|
+
|
|
48
|
+
err := Run([]string{"apply", "--help"}, &out, &errBuf)
|
|
49
|
+
if err != nil {
|
|
50
|
+
t.Fatalf("Run returned error: %v", err)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
got := out.String()
|
|
54
|
+
if !strings.Contains(got, "return package") {
|
|
55
|
+
t.Fatalf("expected return package terminology in apply help, got: %q", got)
|
|
56
|
+
}
|
|
57
|
+
if strings.Contains(got, "<reply.zip>") {
|
|
58
|
+
t.Fatalf("unexpected stale reply terminology in apply help, got: %q", got)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func TestRunPrepareCommand(t *testing.T) {
|
|
63
|
+
originalRunPrepare := runPrepare
|
|
64
|
+
t.Cleanup(func() {
|
|
65
|
+
runPrepare = originalRunPrepare
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
runPrepare = func(opts prepare.Options) (prepare.Result, error) {
|
|
69
|
+
if opts.ToolVersion == "" {
|
|
70
|
+
t.Fatal("expected tool version to be passed into prepare options")
|
|
71
|
+
}
|
|
72
|
+
return prepare.Result{
|
|
73
|
+
ExchangeID: "20260320-120000-a1b2c3",
|
|
74
|
+
TaskRootRef: "refs/heads/procoder/20260320-120000-a1b2c3/task",
|
|
75
|
+
TaskPackagePath: "/tmp/procoder-task-20260320-120000-a1b2c3.zip",
|
|
76
|
+
}, nil
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
var out bytes.Buffer
|
|
80
|
+
var errBuf bytes.Buffer
|
|
81
|
+
|
|
82
|
+
err := Run([]string{"prepare"}, &out, &errBuf)
|
|
83
|
+
if err != nil {
|
|
84
|
+
t.Fatalf("Run returned error: %v", err)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
got := out.String()
|
|
88
|
+
if !strings.Contains(got, "Prepared exchange.") {
|
|
89
|
+
t.Fatalf("expected prepare success header, got: %q", got)
|
|
90
|
+
}
|
|
91
|
+
if !strings.Contains(got, "refs/heads/procoder/20260320-120000-a1b2c3/task") {
|
|
92
|
+
t.Fatalf("expected task branch in output, got: %q", got)
|
|
93
|
+
}
|
|
94
|
+
if !strings.Contains(got, "/tmp/procoder-task-20260320-120000-a1b2c3.zip") {
|
|
95
|
+
t.Fatalf("expected task package path in output, got: %q", got)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func TestRunApplyDryRunCommand(t *testing.T) {
|
|
100
|
+
originalRunApplyDryRun := runApplyDryRun
|
|
101
|
+
originalRunApply := runApply
|
|
102
|
+
t.Cleanup(func() {
|
|
103
|
+
runApplyDryRun = originalRunApplyDryRun
|
|
104
|
+
runApply = originalRunApply
|
|
105
|
+
})
|
|
106
|
+
runApply = func(opts apply.Options) (apply.Result, error) {
|
|
107
|
+
t.Fatalf("write-mode apply should not be called during dry-run test: %#v", opts)
|
|
108
|
+
return apply.Result{}, nil
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
runApplyDryRun = func(opts apply.Options) (apply.Plan, error) {
|
|
112
|
+
if opts.ReturnPackagePath != "procoder-return-20260320-120000-a1b2c3.zip" {
|
|
113
|
+
t.Fatalf("unexpected return package path: %q", opts.ReturnPackagePath)
|
|
114
|
+
}
|
|
115
|
+
if opts.Namespace != "procoder-import" {
|
|
116
|
+
t.Fatalf("unexpected namespace: %q", opts.Namespace)
|
|
117
|
+
}
|
|
118
|
+
return apply.Plan{
|
|
119
|
+
ExchangeID: "20260320-120000-a1b2c3",
|
|
120
|
+
ReturnPackagePath: "/tmp/procoder-return-20260320-120000-a1b2c3.zip",
|
|
121
|
+
Namespace: "procoder-import",
|
|
122
|
+
Checks: []apply.Check{
|
|
123
|
+
{Name: "bundle verification", Detail: "git bundle verify passed"},
|
|
124
|
+
},
|
|
125
|
+
Entries: []apply.PlanEntry{
|
|
126
|
+
{
|
|
127
|
+
Action: apply.ActionUpdate,
|
|
128
|
+
SourceRef: "refs/heads/procoder/20260320-120000-a1b2c3/task",
|
|
129
|
+
DestinationRef: "refs/heads/procoder-import/20260320-120000-a1b2c3/task",
|
|
130
|
+
OldOID: "old",
|
|
131
|
+
NewOID: "new",
|
|
132
|
+
Remapped: true,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
Summary: apply.Summary{Updates: 1},
|
|
136
|
+
}, nil
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
var out bytes.Buffer
|
|
140
|
+
var errBuf bytes.Buffer
|
|
141
|
+
err := Run([]string{
|
|
142
|
+
"apply",
|
|
143
|
+
"procoder-return-20260320-120000-a1b2c3.zip",
|
|
144
|
+
"--dry-run",
|
|
145
|
+
"--namespace",
|
|
146
|
+
"procoder-import",
|
|
147
|
+
}, &out, &errBuf)
|
|
148
|
+
if err != nil {
|
|
149
|
+
t.Fatalf("Run returned error: %v", err)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
got := out.String()
|
|
153
|
+
if !strings.Contains(got, "Dry-run apply plan.") {
|
|
154
|
+
t.Fatalf("expected dry-run plan header, got: %q", got)
|
|
155
|
+
}
|
|
156
|
+
if !strings.Contains(got, "REMAP") {
|
|
157
|
+
t.Fatalf("expected remap output, got: %q", got)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func TestRunApplyWriteModeCommand(t *testing.T) {
|
|
162
|
+
originalRunApplyDryRun := runApplyDryRun
|
|
163
|
+
originalRunApply := runApply
|
|
164
|
+
t.Cleanup(func() {
|
|
165
|
+
runApplyDryRun = originalRunApplyDryRun
|
|
166
|
+
runApply = originalRunApply
|
|
167
|
+
})
|
|
168
|
+
runApplyDryRun = func(opts apply.Options) (apply.Plan, error) {
|
|
169
|
+
t.Fatalf("dry-run apply should not be called in write-mode test: %#v", opts)
|
|
170
|
+
return apply.Plan{}, nil
|
|
171
|
+
}
|
|
172
|
+
runApply = func(opts apply.Options) (apply.Result, error) {
|
|
173
|
+
if opts.ReturnPackagePath != "procoder-return-20260320-120000-a1b2c3.zip" {
|
|
174
|
+
t.Fatalf("unexpected return package path: %q", opts.ReturnPackagePath)
|
|
175
|
+
}
|
|
176
|
+
if opts.Namespace != "procoder-import" {
|
|
177
|
+
t.Fatalf("unexpected namespace: %q", opts.Namespace)
|
|
178
|
+
}
|
|
179
|
+
if !opts.Checkout {
|
|
180
|
+
t.Fatalf("expected checkout=true in apply options")
|
|
181
|
+
}
|
|
182
|
+
return apply.Result{
|
|
183
|
+
Plan: apply.Plan{
|
|
184
|
+
ExchangeID: "20260320-120000-a1b2c3",
|
|
185
|
+
ReturnPackagePath: "/tmp/procoder-return-20260320-120000-a1b2c3.zip",
|
|
186
|
+
Namespace: "procoder-import",
|
|
187
|
+
Summary: apply.Summary{Creates: 1, Updates: 1},
|
|
188
|
+
},
|
|
189
|
+
CheckedOutRef: "refs/heads/procoder-import/20260320-120000-a1b2c3/task",
|
|
190
|
+
}, nil
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
var out bytes.Buffer
|
|
194
|
+
var errBuf bytes.Buffer
|
|
195
|
+
|
|
196
|
+
err := Run([]string{
|
|
197
|
+
"apply",
|
|
198
|
+
"procoder-return-20260320-120000-a1b2c3.zip",
|
|
199
|
+
"--namespace",
|
|
200
|
+
"procoder-import",
|
|
201
|
+
"--checkout",
|
|
202
|
+
}, &out, &errBuf)
|
|
203
|
+
if err != nil {
|
|
204
|
+
t.Fatalf("Run returned error: %v", err)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
got := out.String()
|
|
208
|
+
if !strings.Contains(got, "Applied return package.") {
|
|
209
|
+
t.Fatalf("expected apply success output, got: %q", got)
|
|
210
|
+
}
|
|
211
|
+
if !strings.Contains(got, "Checked out: refs/heads/procoder-import/20260320-120000-a1b2c3/task") {
|
|
212
|
+
t.Fatalf("expected checked-out ref in output, got: %q", got)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
func TestRunApplyMissingPackagePath(t *testing.T) {
|
|
217
|
+
var out bytes.Buffer
|
|
218
|
+
var errBuf bytes.Buffer
|
|
219
|
+
|
|
220
|
+
err := Run([]string{"apply", "--dry-run"}, &out, &errBuf)
|
|
221
|
+
if err == nil {
|
|
222
|
+
t.Fatal("expected error")
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
typed, ok := errs.As(err)
|
|
226
|
+
if !ok {
|
|
227
|
+
t.Fatalf("expected typed error, got %T", err)
|
|
228
|
+
}
|
|
229
|
+
if typed.Code != errs.CodeUnknownCommand {
|
|
230
|
+
t.Fatalf("unexpected code: got %s want %s", typed.Code, errs.CodeUnknownCommand)
|
|
231
|
+
}
|
|
232
|
+
if !strings.Contains(typed.Message, "missing return package path") {
|
|
233
|
+
t.Fatalf("expected missing path message, got %q", typed.Message)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func TestRunUnknownCommand(t *testing.T) {
|
|
238
|
+
var out bytes.Buffer
|
|
239
|
+
var errBuf bytes.Buffer
|
|
240
|
+
|
|
241
|
+
err := Run([]string{"unknown"}, &out, &errBuf)
|
|
242
|
+
if err == nil {
|
|
243
|
+
t.Fatal("expected error for unknown command")
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
typed, ok := errs.As(err)
|
|
247
|
+
if !ok {
|
|
248
|
+
t.Fatalf("expected typed error, got %T", err)
|
|
249
|
+
}
|
|
250
|
+
if typed.Code != errs.CodeUnknownCommand {
|
|
251
|
+
t.Fatalf("unexpected error code: %s", typed.Code)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
package app_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"archive/zip"
|
|
5
|
+
"bytes"
|
|
6
|
+
"io"
|
|
7
|
+
"os"
|
|
8
|
+
"os/exec"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"testing"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
apppkg "github.com/amxv/procoder/internal/app"
|
|
15
|
+
"github.com/amxv/procoder/internal/exchange"
|
|
16
|
+
"github.com/amxv/procoder/internal/returnpkg"
|
|
17
|
+
"github.com/amxv/procoder/internal/testutil/gitrepo"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
func TestCLIEndToEndPrepareReturnApply(t *testing.T) {
|
|
21
|
+
source := gitrepo.New(t)
|
|
22
|
+
source.WriteFile("README.md", "source\n")
|
|
23
|
+
source.CommitAll("initial")
|
|
24
|
+
|
|
25
|
+
helperPath := writeHelperBinary(t)
|
|
26
|
+
t.Setenv("PROCODER_RETURN_HELPER", helperPath)
|
|
27
|
+
t.Chdir(source.Dir)
|
|
28
|
+
|
|
29
|
+
var stdout bytes.Buffer
|
|
30
|
+
var stderr bytes.Buffer
|
|
31
|
+
if err := apppkg.Run([]string{"prepare"}, &stdout, &stderr); err != nil {
|
|
32
|
+
t.Fatalf("app.Run prepare failed: %v", err)
|
|
33
|
+
}
|
|
34
|
+
if !strings.Contains(stdout.String(), "Prepared exchange.") {
|
|
35
|
+
t.Fatalf("expected prepare success output, got:\n%s", stdout.String())
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
taskPackages, err := filepath.Glob(filepath.Join(source.Dir, "procoder-task-*.zip"))
|
|
39
|
+
if err != nil {
|
|
40
|
+
t.Fatalf("glob task package failed: %v", err)
|
|
41
|
+
}
|
|
42
|
+
if len(taskPackages) != 1 {
|
|
43
|
+
t.Fatalf("expected exactly one task package, got %d (%v)", len(taskPackages), taskPackages)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
exportRoot := unzipTaskPackage(t, taskPackages[0])
|
|
47
|
+
ex, err := exchange.ReadExchange(filepath.Join(exportRoot, ".git", "procoder", "exchange.json"))
|
|
48
|
+
if err != nil {
|
|
49
|
+
t.Fatalf("ReadExchange(export) failed: %v", err)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
appendFile(t, filepath.Join(exportRoot, "README.md"), "remote change\n")
|
|
53
|
+
runGit(t, exportRoot, "add", "README.md")
|
|
54
|
+
runGit(t, exportRoot, "commit", "-m", "remote task update")
|
|
55
|
+
|
|
56
|
+
retResult, err := returnpkg.Run(returnpkg.Options{
|
|
57
|
+
CWD: exportRoot,
|
|
58
|
+
ToolVersion: "0.1.0-test",
|
|
59
|
+
Now: func() time.Time { return time.Date(2026, time.March, 20, 16, 0, 0, 0, time.UTC) },
|
|
60
|
+
})
|
|
61
|
+
if err != nil {
|
|
62
|
+
t.Fatalf("returnpkg.Run failed: %v", err)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
retRecord := readReturnRecordFromZip(t, retResult.ReturnPackagePath)
|
|
66
|
+
taskUpdate, ok := findUpdate(retRecord.Updates, ex.Task.RootRef)
|
|
67
|
+
if !ok {
|
|
68
|
+
t.Fatalf("expected task root update in return record")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
stdout.Reset()
|
|
72
|
+
stderr.Reset()
|
|
73
|
+
if err := apppkg.Run([]string{"apply", retResult.ReturnPackagePath, "--checkout"}, &stdout, &stderr); err != nil {
|
|
74
|
+
t.Fatalf("app.Run apply failed: %v", err)
|
|
75
|
+
}
|
|
76
|
+
if !strings.Contains(stdout.String(), "Applied return package.") {
|
|
77
|
+
t.Fatalf("expected apply success output, got:\n%s", stdout.String())
|
|
78
|
+
}
|
|
79
|
+
if !strings.Contains(stdout.String(), "Checked out: "+ex.Task.RootRef) {
|
|
80
|
+
t.Fatalf("expected checked-out ref in apply output, got:\n%s", stdout.String())
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if got := strings.TrimSpace(source.Git("rev-parse", ex.Task.RootRef)); got != taskUpdate.NewOID {
|
|
84
|
+
t.Fatalf("task ref mismatch after CLI apply: got %q want %q", got, taskUpdate.NewOID)
|
|
85
|
+
}
|
|
86
|
+
if got := strings.TrimSpace(source.Git("symbolic-ref", "--quiet", "HEAD")); got != ex.Task.RootRef {
|
|
87
|
+
t.Fatalf("expected CLI apply to check out task ref: got %q want %q", got, ex.Task.RootRef)
|
|
88
|
+
}
|
|
89
|
+
assertNoImportRefs(t, source.Dir)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func writeHelperBinary(t *testing.T) string {
|
|
93
|
+
t.Helper()
|
|
94
|
+
|
|
95
|
+
path := filepath.Join(t.TempDir(), "procoder-return")
|
|
96
|
+
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
|
97
|
+
t.Fatalf("write helper binary failed: %v", err)
|
|
98
|
+
}
|
|
99
|
+
return path
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func readReturnRecordFromZip(t *testing.T, zipPath string) exchange.Return {
|
|
103
|
+
t.Helper()
|
|
104
|
+
|
|
105
|
+
extracted := unzipFile(t, zipPath)
|
|
106
|
+
record, err := exchange.ReadReturn(filepath.Join(extracted, "procoder-return.json"))
|
|
107
|
+
if err != nil {
|
|
108
|
+
t.Fatalf("ReadReturn failed: %v", err)
|
|
109
|
+
}
|
|
110
|
+
return record
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func unzipTaskPackage(t *testing.T, zipPath string) string {
|
|
114
|
+
t.Helper()
|
|
115
|
+
|
|
116
|
+
dest := unzipFile(t, zipPath)
|
|
117
|
+
return filepath.Join(dest, filepath.Base(filepath.Dir(zipPath)))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func unzipFile(t *testing.T, zipPath string) string {
|
|
121
|
+
t.Helper()
|
|
122
|
+
|
|
123
|
+
reader, err := zip.OpenReader(zipPath)
|
|
124
|
+
if err != nil {
|
|
125
|
+
t.Fatalf("zip.OpenReader failed: %v", err)
|
|
126
|
+
}
|
|
127
|
+
defer reader.Close()
|
|
128
|
+
|
|
129
|
+
dest := t.TempDir()
|
|
130
|
+
for _, file := range reader.File {
|
|
131
|
+
targetPath := filepath.Join(dest, file.Name)
|
|
132
|
+
safePrefix := filepath.Clean(dest) + string(os.PathSeparator)
|
|
133
|
+
if !strings.HasPrefix(filepath.Clean(targetPath), safePrefix) {
|
|
134
|
+
t.Fatalf("zip entry escapes destination: %q", file.Name)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if file.FileInfo().IsDir() {
|
|
138
|
+
if err := os.MkdirAll(targetPath, 0o755); err != nil {
|
|
139
|
+
t.Fatalf("mkdir failed: %v", err)
|
|
140
|
+
}
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
|
145
|
+
t.Fatalf("mkdir file dir failed: %v", err)
|
|
146
|
+
}
|
|
147
|
+
in, err := file.Open()
|
|
148
|
+
if err != nil {
|
|
149
|
+
t.Fatalf("open zip entry failed: %v", err)
|
|
150
|
+
}
|
|
151
|
+
out, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, file.Mode())
|
|
152
|
+
if err != nil {
|
|
153
|
+
_ = in.Close()
|
|
154
|
+
t.Fatalf("open extracted file failed: %v", err)
|
|
155
|
+
}
|
|
156
|
+
if _, err := io.Copy(out, in); err != nil {
|
|
157
|
+
_ = out.Close()
|
|
158
|
+
_ = in.Close()
|
|
159
|
+
t.Fatalf("copy extracted file failed: %v", err)
|
|
160
|
+
}
|
|
161
|
+
_ = out.Close()
|
|
162
|
+
_ = in.Close()
|
|
163
|
+
}
|
|
164
|
+
return dest
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func appendFile(t *testing.T, path, extra string) {
|
|
168
|
+
t.Helper()
|
|
169
|
+
|
|
170
|
+
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
171
|
+
if err != nil {
|
|
172
|
+
t.Fatalf("open %s for append failed: %v", path, err)
|
|
173
|
+
}
|
|
174
|
+
if _, err := f.WriteString(extra); err != nil {
|
|
175
|
+
_ = f.Close()
|
|
176
|
+
t.Fatalf("append to %s failed: %v", path, err)
|
|
177
|
+
}
|
|
178
|
+
if err := f.Close(); err != nil {
|
|
179
|
+
t.Fatalf("close %s failed: %v", path, err)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func runGit(t *testing.T, dir string, args ...string) string {
|
|
184
|
+
t.Helper()
|
|
185
|
+
|
|
186
|
+
cmd := exec.Command("git", args...)
|
|
187
|
+
cmd.Dir = dir
|
|
188
|
+
var stdout bytes.Buffer
|
|
189
|
+
var stderr bytes.Buffer
|
|
190
|
+
cmd.Stdout = &stdout
|
|
191
|
+
cmd.Stderr = &stderr
|
|
192
|
+
|
|
193
|
+
if err := cmd.Run(); err != nil {
|
|
194
|
+
t.Fatalf("git %s failed:\nstdout:\n%s\nstderr:\n%s\nerr:%v", strings.Join(args, " "), stdout.String(), stderr.String(), err)
|
|
195
|
+
}
|
|
196
|
+
return stdout.String()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
func findUpdate(updates []exchange.RefUpdate, ref string) (exchange.RefUpdate, bool) {
|
|
200
|
+
for _, update := range updates {
|
|
201
|
+
if update.Ref == ref {
|
|
202
|
+
return update, true
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return exchange.RefUpdate{}, false
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func assertNoImportRefs(t *testing.T, dir string) {
|
|
209
|
+
t.Helper()
|
|
210
|
+
|
|
211
|
+
importRefs := strings.TrimSpace(runGit(t, dir, "for-each-ref", "--format=%(refname)", "refs/procoder/import"))
|
|
212
|
+
if importRefs != "" {
|
|
213
|
+
t.Fatalf("expected temporary import refs to be cleaned up, got:\n%s", importRefs)
|
|
214
|
+
}
|
|
215
|
+
}
|