@awiki/cli 0.0.1-beta.2
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/.github/workflows/release.yml +44 -0
- package/.goreleaser.yml +44 -0
- package/AGENTS.md +60 -0
- package/CLAUDE.md +192 -0
- package/README.md +2 -0
- package/docs/architecture/awiki-command-v2.md +955 -0
- package/docs/architecture/awiki-skill-architecture.md +475 -0
- package/docs/architecture/awiki-v2-architecture.md +1063 -0
- package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/cli-init.md +1008 -0
- package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/output-format.md +407 -0
- package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/overall-init.md +741 -0
- package/docs/harness/review-spec.md +474 -0
- package/docs/installation.md +372 -0
- package/docs/plan/awiki-v2-implementation-plan.md +903 -0
- package/docs/plan/phase-0/adr-index.md +56 -0
- package/docs/plan/phase-0/audit-findings.md +251 -0
- package/docs/plan/phase-0/capability-mapping.md +108 -0
- package/docs/plan/phase-0/implementation-constraints.md +363 -0
- package/docs/publish.md +169 -0
- package/go.mod +29 -0
- package/go.sum +73 -0
- package/internal/anpsdk/registry.go +63 -0
- package/internal/authsdk/session.go +351 -0
- package/internal/buildinfo/buildinfo.go +34 -0
- package/internal/cli/app.go +136 -0
- package/internal/cli/app_test.go +88 -0
- package/internal/cli/debug.go +104 -0
- package/internal/cli/group.go +263 -0
- package/internal/cli/id.go +473 -0
- package/internal/cli/init.go +134 -0
- package/internal/cli/msg.go +228 -0
- package/internal/cli/page.go +267 -0
- package/internal/cli/root.go +499 -0
- package/internal/cli/runtime.go +232 -0
- package/internal/cli/upgrade.go +60 -0
- package/internal/cmdmeta/catalog.go +203 -0
- package/internal/cmdmeta/catalog_test.go +21 -0
- package/internal/config/config.go +399 -0
- package/internal/config/config_test.go +104 -0
- package/internal/config/write.go +37 -0
- package/internal/content/service.go +314 -0
- package/internal/content/service_test.go +165 -0
- package/internal/content/types.go +44 -0
- package/internal/docs/topics.go +110 -0
- package/internal/doctor/doctor.go +306 -0
- package/internal/identity/client.go +267 -0
- package/internal/identity/did.go +85 -0
- package/internal/identity/did_test.go +50 -0
- package/internal/identity/layout.go +206 -0
- package/internal/identity/legacy.go +378 -0
- package/internal/identity/public.go +70 -0
- package/internal/identity/public_test.go +73 -0
- package/internal/identity/readiness.go +74 -0
- package/internal/identity/service.go +826 -0
- package/internal/identity/store.go +385 -0
- package/internal/identity/store_test.go +180 -0
- package/internal/identity/types.go +204 -0
- package/internal/message/auth.go +167 -0
- package/internal/message/group_service.go +838 -0
- package/internal/message/group_wire.go +350 -0
- package/internal/message/group_wire_test.go +67 -0
- package/internal/message/helpers.go +61 -0
- package/internal/message/http_client.go +334 -0
- package/internal/message/proof.go +156 -0
- package/internal/message/proof_test.go +61 -0
- package/internal/message/service.go +696 -0
- package/internal/message/service_test.go +97 -0
- package/internal/message/types.go +155 -0
- package/internal/message/wire.go +100 -0
- package/internal/message/wire_test.go +49 -0
- package/internal/message/ws_proxy_client.go +151 -0
- package/internal/output/output.go +350 -0
- package/internal/output/output_test.go +48 -0
- package/internal/runtime/config.go +117 -0
- package/internal/runtime/config_test.go +46 -0
- package/internal/runtime/listener/files.go +65 -0
- package/internal/runtime/listener/manager.go +142 -0
- package/internal/runtime/listener/server.go +983 -0
- package/internal/runtime/listener/server_test.go +319 -0
- package/internal/runtime/listener/sysproc_unix.go +17 -0
- package/internal/runtime/listener/sysproc_windows.go +13 -0
- package/internal/runtime/listener/types.go +21 -0
- package/internal/runtime/listener/wsclient.go +299 -0
- package/internal/runtime/listener/wsclient_test.go +41 -0
- package/internal/store/dao.go +632 -0
- package/internal/store/dao_test.go +87 -0
- package/internal/store/helpers.go +197 -0
- package/internal/store/import.go +499 -0
- package/internal/store/import_test.go +103 -0
- package/internal/store/open.go +71 -0
- package/internal/store/query.go +151 -0
- package/internal/store/schema.go +277 -0
- package/internal/store/schema_test.go +56 -0
- package/internal/store/types.go +177 -0
- package/internal/update/update.go +368 -0
- package/package.json +17 -0
- package/scripts/install.js +171 -0
- package/scripts/release/release-prerelease.sh +86 -0
- package/scripts/release/tag-release.sh +66 -0
- package/scripts/release/withdraw-release.sh +78 -0
- package/scripts/run.js +69 -0
- package/skills/README.md +32 -0
- package/skills/awiki-bundle/SKILL.md +76 -0
- package/skills/awiki-debug/SKILL.md +80 -0
- package/skills/awiki-group/SKILL.md +111 -0
- package/skills/awiki-id/SKILL.md +123 -0
- package/skills/awiki-msg/SKILL.md +131 -0
- package/skills/awiki-page/SKILL.md +93 -0
- package/skills/awiki-people/SKILL.md +66 -0
- package/skills/awiki-runtime/SKILL.md +137 -0
- package/skills/awiki-shared/SKILL.md +124 -0
- package/skills/awiki-workflow-discovery/SKILL.md +93 -0
- package/skills/awiki-workflow-onboarding/SKILL.md +119 -0
- package/skills/manifests/skills.yaml +260 -0
- package/skills/templates/bundle-skill-template.md +42 -0
- package/skills/templates/debug-skill-template.md +44 -0
- package/skills/templates/domain-skill-template.md +56 -0
- package/skills/templates/shared-skill-template.md +46 -0
- package/skills/templates/workflow-skill-template.md +46 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
package output
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"sort"
|
|
9
|
+
"strings"
|
|
10
|
+
"text/tabwriter"
|
|
11
|
+
|
|
12
|
+
"github.com/itchyny/gojq"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
type Format string
|
|
16
|
+
|
|
17
|
+
const (
|
|
18
|
+
FormatJSON Format = "json"
|
|
19
|
+
FormatPretty Format = "pretty"
|
|
20
|
+
FormatNDJSON Format = "ndjson"
|
|
21
|
+
FormatTable Format = "table"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
type IdentityMeta struct {
|
|
25
|
+
Name string `json:"name,omitempty"`
|
|
26
|
+
DID string `json:"did,omitempty"`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Meta struct {
|
|
30
|
+
Version string `json:"version"`
|
|
31
|
+
Identity *IdentityMeta `json:"identity,omitempty"`
|
|
32
|
+
DryRun bool `json:"dry_run"`
|
|
33
|
+
Format string `json:"format"`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type SuccessEnvelope struct {
|
|
37
|
+
OK bool `json:"ok"`
|
|
38
|
+
Command string `json:"command"`
|
|
39
|
+
Data any `json:"data,omitempty"`
|
|
40
|
+
Warnings []string `json:"warnings,omitempty"`
|
|
41
|
+
Summary string `json:"summary,omitempty"`
|
|
42
|
+
Notice map[string]any `json:"_notice,omitempty"`
|
|
43
|
+
Meta Meta `json:"meta"`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type ErrorDetail struct {
|
|
47
|
+
Code string `json:"code"`
|
|
48
|
+
Message string `json:"message"`
|
|
49
|
+
Hint string `json:"hint,omitempty"`
|
|
50
|
+
Retryable bool `json:"retryable"`
|
|
51
|
+
Details any `json:"details,omitempty"`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type ErrorEnvelope struct {
|
|
55
|
+
OK bool `json:"ok"`
|
|
56
|
+
Error ErrorDetail `json:"error"`
|
|
57
|
+
Notice map[string]any `json:"_notice,omitempty"`
|
|
58
|
+
Meta Meta `json:"meta"`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type ExitError struct {
|
|
62
|
+
Code int
|
|
63
|
+
Detail ErrorDetail
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func (e *ExitError) Error() string {
|
|
67
|
+
if e == nil {
|
|
68
|
+
return ""
|
|
69
|
+
}
|
|
70
|
+
return e.Detail.Message
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func NewExitError(code string, exitCode int, message string, hint string) *ExitError {
|
|
74
|
+
return &ExitError{
|
|
75
|
+
Code: exitCode,
|
|
76
|
+
Detail: ErrorDetail{
|
|
77
|
+
Code: code,
|
|
78
|
+
Message: message,
|
|
79
|
+
Hint: hint,
|
|
80
|
+
Retryable: false,
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func NormalizeFormat(raw string) (Format, error) {
|
|
86
|
+
normalized := Format(strings.ToLower(strings.TrimSpace(raw)))
|
|
87
|
+
switch normalized {
|
|
88
|
+
case FormatJSON, FormatPretty, FormatNDJSON, FormatTable:
|
|
89
|
+
return normalized, nil
|
|
90
|
+
default:
|
|
91
|
+
return "", fmt.Errorf("unsupported format %q", raw)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func RenderSuccess(w io.Writer, format Format, jqExpr string, envelope SuccessEnvelope) error {
|
|
96
|
+
return render(w, format, jqExpr, envelope)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func RenderError(w io.Writer, format Format, jqExpr string, envelope ErrorEnvelope) error {
|
|
100
|
+
return render(w, format, jqExpr, envelope)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func render(w io.Writer, format Format, jqExpr string, envelope any) error {
|
|
104
|
+
value, err := toGeneric(envelope)
|
|
105
|
+
if err != nil {
|
|
106
|
+
return err
|
|
107
|
+
}
|
|
108
|
+
if strings.TrimSpace(jqExpr) != "" {
|
|
109
|
+
value, err = applyJQ(value, jqExpr)
|
|
110
|
+
if err != nil {
|
|
111
|
+
return err
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return writeValue(w, format, value)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func toGeneric(value any) (any, error) {
|
|
118
|
+
raw, err := json.Marshal(value)
|
|
119
|
+
if err != nil {
|
|
120
|
+
return nil, err
|
|
121
|
+
}
|
|
122
|
+
var decoded any
|
|
123
|
+
if err := json.Unmarshal(raw, &decoded); err != nil {
|
|
124
|
+
return nil, err
|
|
125
|
+
}
|
|
126
|
+
return decoded, nil
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func applyJQ(value any, expr string) (any, error) {
|
|
130
|
+
query, err := gojq.Parse(expr)
|
|
131
|
+
if err != nil {
|
|
132
|
+
return nil, fmt.Errorf("invalid jq expression: %w", err)
|
|
133
|
+
}
|
|
134
|
+
iter := query.Run(value)
|
|
135
|
+
results := make([]any, 0)
|
|
136
|
+
for {
|
|
137
|
+
item, ok := iter.Next()
|
|
138
|
+
if !ok {
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
if jqErr, ok := item.(error); ok {
|
|
142
|
+
return nil, fmt.Errorf("jq execution failed: %w", jqErr)
|
|
143
|
+
}
|
|
144
|
+
results = append(results, item)
|
|
145
|
+
}
|
|
146
|
+
if len(results) == 0 {
|
|
147
|
+
return nil, nil
|
|
148
|
+
}
|
|
149
|
+
if len(results) == 1 {
|
|
150
|
+
return results[0], nil
|
|
151
|
+
}
|
|
152
|
+
return results, nil
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func writeValue(w io.Writer, format Format, value any) error {
|
|
156
|
+
switch format {
|
|
157
|
+
case FormatJSON:
|
|
158
|
+
return writeJSON(w, value, true)
|
|
159
|
+
case FormatPretty:
|
|
160
|
+
return writeJSON(w, value, true)
|
|
161
|
+
case FormatNDJSON:
|
|
162
|
+
return writeNDJSON(w, value)
|
|
163
|
+
case FormatTable:
|
|
164
|
+
return writeTable(w, value)
|
|
165
|
+
default:
|
|
166
|
+
return fmt.Errorf("unsupported format %q", format)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func writeJSON(w io.Writer, value any, indent bool) error {
|
|
171
|
+
var (
|
|
172
|
+
raw []byte
|
|
173
|
+
err error
|
|
174
|
+
)
|
|
175
|
+
if indent {
|
|
176
|
+
raw, err = json.MarshalIndent(value, "", " ")
|
|
177
|
+
} else {
|
|
178
|
+
raw, err = json.Marshal(value)
|
|
179
|
+
}
|
|
180
|
+
if err != nil {
|
|
181
|
+
return err
|
|
182
|
+
}
|
|
183
|
+
_, err = fmt.Fprintln(w, string(raw))
|
|
184
|
+
return err
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
func writeNDJSON(w io.Writer, value any) error {
|
|
188
|
+
if rows, ok := value.([]any); ok {
|
|
189
|
+
for _, row := range rows {
|
|
190
|
+
if err := writeJSON(w, row, false); err != nil {
|
|
191
|
+
return err
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return nil
|
|
195
|
+
}
|
|
196
|
+
return writeJSON(w, value, false)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
func writeTable(w io.Writer, value any) error {
|
|
200
|
+
value = tableViewValue(value)
|
|
201
|
+
if rows, ok := value.([]any); ok {
|
|
202
|
+
return writeTableRows(w, rows)
|
|
203
|
+
}
|
|
204
|
+
if object, ok := value.(map[string]any); ok {
|
|
205
|
+
return writeTableObject(w, object)
|
|
206
|
+
}
|
|
207
|
+
return writeJSON(w, value, true)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func tableViewValue(value any) any {
|
|
211
|
+
value = unwrapTableEnvelope(value)
|
|
212
|
+
if object, ok := value.(map[string]any); ok {
|
|
213
|
+
if _, hasCommand := object["command"]; hasCommand {
|
|
214
|
+
return value
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if rows, ok := preferredTableRows(value); ok {
|
|
218
|
+
return rows
|
|
219
|
+
}
|
|
220
|
+
return value
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
func unwrapTableEnvelope(value any) any {
|
|
224
|
+
object, ok := value.(map[string]any)
|
|
225
|
+
if !ok {
|
|
226
|
+
return value
|
|
227
|
+
}
|
|
228
|
+
okValue, hasOK := object["ok"].(bool)
|
|
229
|
+
if !hasOK {
|
|
230
|
+
return value
|
|
231
|
+
}
|
|
232
|
+
if okValue {
|
|
233
|
+
if data, exists := object["data"]; exists {
|
|
234
|
+
return data
|
|
235
|
+
}
|
|
236
|
+
return value
|
|
237
|
+
}
|
|
238
|
+
if detail, exists := object["error"]; exists {
|
|
239
|
+
return detail
|
|
240
|
+
}
|
|
241
|
+
return value
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func preferredTableRows(value any) ([]any, bool) {
|
|
245
|
+
object, ok := value.(map[string]any)
|
|
246
|
+
if !ok {
|
|
247
|
+
return nil, false
|
|
248
|
+
}
|
|
249
|
+
for _, key := range []string{"rows", "items", "messages", "members", "pages", "identities", "groups", "followers", "following", "checks", "commands"} {
|
|
250
|
+
if rows, ok := tableRows(object[key]); ok {
|
|
251
|
+
return rows, true
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
sliceKeys := make([]string, 0, len(object))
|
|
255
|
+
for key, item := range object {
|
|
256
|
+
if _, ok := tableRows(item); ok {
|
|
257
|
+
sliceKeys = append(sliceKeys, key)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if len(sliceKeys) != 1 {
|
|
261
|
+
return nil, false
|
|
262
|
+
}
|
|
263
|
+
return tableRows(object[sliceKeys[0]])
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
func tableRows(value any) ([]any, bool) {
|
|
267
|
+
rows, ok := value.([]any)
|
|
268
|
+
return rows, ok
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func writeTableObject(w io.Writer, value map[string]any) error {
|
|
272
|
+
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
|
273
|
+
keys := make([]string, 0, len(value))
|
|
274
|
+
for key := range value {
|
|
275
|
+
keys = append(keys, key)
|
|
276
|
+
}
|
|
277
|
+
sort.Strings(keys)
|
|
278
|
+
for _, key := range keys {
|
|
279
|
+
cell, err := tableCell(value[key])
|
|
280
|
+
if err != nil {
|
|
281
|
+
return err
|
|
282
|
+
}
|
|
283
|
+
if _, err := fmt.Fprintf(tw, "%s\t%s\n", key, cell); err != nil {
|
|
284
|
+
return err
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return tw.Flush()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
func writeTableRows(w io.Writer, rows []any) error {
|
|
291
|
+
if len(rows) == 0 {
|
|
292
|
+
_, err := fmt.Fprintln(w, "No rows")
|
|
293
|
+
return err
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
objects := make([]map[string]any, 0, len(rows))
|
|
297
|
+
columnsSet := map[string]struct{}{}
|
|
298
|
+
for _, row := range rows {
|
|
299
|
+
object, ok := row.(map[string]any)
|
|
300
|
+
if !ok {
|
|
301
|
+
return writeJSON(w, rows, true)
|
|
302
|
+
}
|
|
303
|
+
objects = append(objects, object)
|
|
304
|
+
for key := range object {
|
|
305
|
+
columnsSet[key] = struct{}{}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
columns := make([]string, 0, len(columnsSet))
|
|
310
|
+
for column := range columnsSet {
|
|
311
|
+
columns = append(columns, column)
|
|
312
|
+
}
|
|
313
|
+
sort.Strings(columns)
|
|
314
|
+
|
|
315
|
+
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
|
316
|
+
if _, err := fmt.Fprintln(tw, strings.Join(columns, "\t")); err != nil {
|
|
317
|
+
return err
|
|
318
|
+
}
|
|
319
|
+
for _, row := range objects {
|
|
320
|
+
cells := make([]string, 0, len(columns))
|
|
321
|
+
for _, column := range columns {
|
|
322
|
+
cell, err := tableCell(row[column])
|
|
323
|
+
if err != nil {
|
|
324
|
+
return err
|
|
325
|
+
}
|
|
326
|
+
cells = append(cells, cell)
|
|
327
|
+
}
|
|
328
|
+
if _, err := fmt.Fprintln(tw, strings.Join(cells, "\t")); err != nil {
|
|
329
|
+
return err
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return tw.Flush()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
func tableCell(value any) (string, error) {
|
|
336
|
+
switch typed := value.(type) {
|
|
337
|
+
case nil:
|
|
338
|
+
return "", nil
|
|
339
|
+
case string:
|
|
340
|
+
return typed, nil
|
|
341
|
+
case bool, float64, float32, int, int32, int64, uint, uint32, uint64:
|
|
342
|
+
return fmt.Sprint(typed), nil
|
|
343
|
+
default:
|
|
344
|
+
buffer := bytes.NewBuffer(nil)
|
|
345
|
+
if err := writeJSON(buffer, typed, false); err != nil {
|
|
346
|
+
return "", err
|
|
347
|
+
}
|
|
348
|
+
return strings.TrimSpace(buffer.String()), nil
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package output
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestRenderSuccessTableUsesCommandDataRows(t *testing.T) {
|
|
10
|
+
t.Parallel()
|
|
11
|
+
|
|
12
|
+
envelope := SuccessEnvelope{
|
|
13
|
+
OK: true,
|
|
14
|
+
Command: "awiki-cli msg inbox",
|
|
15
|
+
Data: map[string]any{
|
|
16
|
+
"messages": []map[string]any{
|
|
17
|
+
{
|
|
18
|
+
"id": "msg-1",
|
|
19
|
+
"text": "hello",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
"total": 1,
|
|
23
|
+
"source": "http",
|
|
24
|
+
},
|
|
25
|
+
Summary: "Loaded inbox",
|
|
26
|
+
Meta: Meta{
|
|
27
|
+
Version: "test",
|
|
28
|
+
Format: string(FormatTable),
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
var buffer bytes.Buffer
|
|
33
|
+
if err := RenderSuccess(&buffer, FormatTable, "", envelope); err != nil {
|
|
34
|
+
t.Fatalf("RenderSuccess() error = %v", err)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
rendered := buffer.String()
|
|
38
|
+
for _, forbidden := range []string{"command", "meta", "data", "ok", "awiki-cli msg inbox"} {
|
|
39
|
+
if strings.Contains(rendered, forbidden) {
|
|
40
|
+
t.Fatalf("table output %q unexpectedly contains envelope field %q", rendered, forbidden)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for _, want := range []string{"msg-1", "hello"} {
|
|
44
|
+
if !strings.Contains(rendered, want) {
|
|
45
|
+
t.Fatalf("table output %q does not contain row value %q", rendered, want)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/sha256"
|
|
5
|
+
"encoding/hex"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"fmt"
|
|
8
|
+
"net"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"runtime"
|
|
12
|
+
"strings"
|
|
13
|
+
|
|
14
|
+
appconfig "github.com/agentconnect/awiki-cli/internal/config"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const (
|
|
18
|
+
ModeHTTP = "http"
|
|
19
|
+
ModeWebSocket = "websocket"
|
|
20
|
+
maxUnixSocketPathBytes = 100
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
type Resolved struct {
|
|
24
|
+
Mode string `json:"mode"`
|
|
25
|
+
SocketPath string `json:"socket_path,omitempty"`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func Resolve(resolved *appconfig.Resolved) Resolved {
|
|
29
|
+
if resolved == nil {
|
|
30
|
+
return Resolved{Mode: ModeHTTP}
|
|
31
|
+
}
|
|
32
|
+
mode := strings.ToLower(strings.TrimSpace(resolved.RuntimeMode))
|
|
33
|
+
if mode != ModeWebSocket {
|
|
34
|
+
mode = ModeHTTP
|
|
35
|
+
}
|
|
36
|
+
socketPath := strings.TrimSpace(resolved.RuntimeSocketPath)
|
|
37
|
+
if socketPath == "" {
|
|
38
|
+
socketPath = filepath.Join(resolved.Paths.StateDir, "runtime", "message-daemon.sock")
|
|
39
|
+
}
|
|
40
|
+
socketPath = normalizeSocketPath(socketPath)
|
|
41
|
+
return Resolved{
|
|
42
|
+
Mode: mode,
|
|
43
|
+
SocketPath: socketPath,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func IsWebSocketMode(resolved *appconfig.Resolved) bool {
|
|
48
|
+
return Resolve(resolved).Mode == ModeWebSocket
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type BridgeRequest struct {
|
|
52
|
+
Method string `json:"method"`
|
|
53
|
+
Params map[string]any `json:"params"`
|
|
54
|
+
IdentityName string `json:"identity_name"`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type BridgeResponse struct {
|
|
58
|
+
OK bool `json:"ok"`
|
|
59
|
+
Result map[string]any `json:"result,omitempty"`
|
|
60
|
+
Error *BridgeError `json:"error,omitempty"`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type BridgeError struct {
|
|
64
|
+
Code string `json:"code,omitempty"`
|
|
65
|
+
Message string `json:"message"`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func CallLocalBridge(request BridgeRequest, resolved *appconfig.Resolved) (map[string]any, error) {
|
|
69
|
+
bridge := Resolve(resolved)
|
|
70
|
+
if bridge.Mode != ModeWebSocket {
|
|
71
|
+
return nil, fmt.Errorf("runtime mode %s does not use the local websocket bridge", bridge.Mode)
|
|
72
|
+
}
|
|
73
|
+
if strings.TrimSpace(bridge.SocketPath) == "" {
|
|
74
|
+
return nil, fmt.Errorf("runtime websocket bridge socket is not configured")
|
|
75
|
+
}
|
|
76
|
+
if runtime.GOOS == "windows" {
|
|
77
|
+
return nil, fmt.Errorf("websocket bridge is not implemented on windows yet")
|
|
78
|
+
}
|
|
79
|
+
if err := os.MkdirAll(filepath.Dir(bridge.SocketPath), 0o700); err != nil {
|
|
80
|
+
return nil, fmt.Errorf("prepare websocket bridge socket dir: %w", err)
|
|
81
|
+
}
|
|
82
|
+
conn, err := net.Dial("unix", bridge.SocketPath)
|
|
83
|
+
if err != nil {
|
|
84
|
+
return nil, fmt.Errorf("local websocket bridge unavailable: %w", err)
|
|
85
|
+
}
|
|
86
|
+
defer conn.Close()
|
|
87
|
+
payload, err := json.Marshal(request)
|
|
88
|
+
if err != nil {
|
|
89
|
+
return nil, err
|
|
90
|
+
}
|
|
91
|
+
if _, err := conn.Write(append(payload, '\n')); err != nil {
|
|
92
|
+
return nil, fmt.Errorf("write websocket bridge request: %w", err)
|
|
93
|
+
}
|
|
94
|
+
decoder := json.NewDecoder(conn)
|
|
95
|
+
var response BridgeResponse
|
|
96
|
+
if err := decoder.Decode(&response); err != nil {
|
|
97
|
+
return nil, fmt.Errorf("decode websocket bridge response: %w", err)
|
|
98
|
+
}
|
|
99
|
+
if !response.OK {
|
|
100
|
+
if response.Error == nil {
|
|
101
|
+
return nil, fmt.Errorf("local websocket bridge request failed")
|
|
102
|
+
}
|
|
103
|
+
return nil, fmt.Errorf("local websocket bridge request failed: %s", response.Error.Message)
|
|
104
|
+
}
|
|
105
|
+
if response.Result == nil {
|
|
106
|
+
return map[string]any{}, nil
|
|
107
|
+
}
|
|
108
|
+
return response.Result, nil
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func normalizeSocketPath(path string) string {
|
|
112
|
+
if len(path) <= maxUnixSocketPathBytes {
|
|
113
|
+
return path
|
|
114
|
+
}
|
|
115
|
+
sum := sha256.Sum256([]byte(path))
|
|
116
|
+
return filepath.Join(os.TempDir(), "awiki-cli-"+hex.EncodeToString(sum[:8])+".sock")
|
|
117
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
appconfig "github.com/agentconnect/awiki-cli/internal/config"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestResolveShortensLongSocketPath(t *testing.T) {
|
|
13
|
+
t.Parallel()
|
|
14
|
+
|
|
15
|
+
longStateDir := filepath.Join("/tmp", strings.Repeat("very-long-runtime-dir-", 10))
|
|
16
|
+
resolved := Resolve(&appconfig.Resolved{
|
|
17
|
+
RuntimeMode: "websocket",
|
|
18
|
+
Paths: appconfig.Paths{
|
|
19
|
+
StateDir: longStateDir,
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
if resolved.Mode != ModeWebSocket {
|
|
23
|
+
t.Fatalf("resolved.Mode = %q, want %q", resolved.Mode, ModeWebSocket)
|
|
24
|
+
}
|
|
25
|
+
if len(resolved.SocketPath) > maxUnixSocketPathBytes {
|
|
26
|
+
t.Fatalf("len(resolved.SocketPath) = %d, want <= %d", len(resolved.SocketPath), maxUnixSocketPathBytes)
|
|
27
|
+
}
|
|
28
|
+
if !strings.HasPrefix(resolved.SocketPath, filepath.Join(os.TempDir(), "awiki-cli-")) {
|
|
29
|
+
t.Fatalf("resolved.SocketPath = %q, want shortened temp-dir path", resolved.SocketPath)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func TestResolveKeepsShortSocketPath(t *testing.T) {
|
|
34
|
+
t.Parallel()
|
|
35
|
+
|
|
36
|
+
resolved := Resolve(&appconfig.Resolved{
|
|
37
|
+
RuntimeMode: "websocket",
|
|
38
|
+
RuntimeSocketPath: "/tmp/custom-awiki.sock",
|
|
39
|
+
Paths: appconfig.Paths{
|
|
40
|
+
StateDir: "/tmp/state",
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
if resolved.SocketPath != "/tmp/custom-awiki.sock" {
|
|
44
|
+
t.Fatalf("resolved.SocketPath = %q, want /tmp/custom-awiki.sock", resolved.SocketPath)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
package listener
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strconv"
|
|
9
|
+
"strings"
|
|
10
|
+
|
|
11
|
+
appconfig "github.com/agentconnect/awiki-cli/internal/config"
|
|
12
|
+
"github.com/agentconnect/awiki-cli/internal/runtime"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
func paths(resolved *appconfig.Resolved) (pidFile string, logFile string, statusFile string, socketPath string, err error) {
|
|
16
|
+
bridge := runtime.Resolve(resolved)
|
|
17
|
+
stateRoot := filepath.Join(resolved.Paths.StateDir, "runtime")
|
|
18
|
+
if err := os.MkdirAll(stateRoot, 0o700); err != nil {
|
|
19
|
+
return "", "", "", "", fmt.Errorf("create runtime state dir: %w", err)
|
|
20
|
+
}
|
|
21
|
+
logDir := strings.TrimSpace(resolved.Paths.LogsDir)
|
|
22
|
+
if logDir == "" {
|
|
23
|
+
logDir = stateRoot
|
|
24
|
+
}
|
|
25
|
+
if err := os.MkdirAll(logDir, 0o700); err != nil {
|
|
26
|
+
return "", "", "", "", fmt.Errorf("create runtime log dir: %w", err)
|
|
27
|
+
}
|
|
28
|
+
return filepath.Join(stateRoot, "listener.pid"),
|
|
29
|
+
filepath.Join(logDir, "listener.log"),
|
|
30
|
+
filepath.Join(stateRoot, "listener.status.json"),
|
|
31
|
+
bridge.SocketPath,
|
|
32
|
+
nil
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func writePID(path string, pid int) error {
|
|
36
|
+
return os.WriteFile(path, []byte(strconv.Itoa(pid)), 0o600)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func readPID(path string) (int, error) {
|
|
40
|
+
raw, err := os.ReadFile(path)
|
|
41
|
+
if err != nil {
|
|
42
|
+
return 0, err
|
|
43
|
+
}
|
|
44
|
+
return strconv.Atoi(strings.TrimSpace(string(raw)))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func writeStatus(path string, status Status) error {
|
|
48
|
+
raw, err := json.MarshalIndent(status, "", " ")
|
|
49
|
+
if err != nil {
|
|
50
|
+
return err
|
|
51
|
+
}
|
|
52
|
+
return os.WriteFile(path, raw, 0o600)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func readStatus(path string) (Status, error) {
|
|
56
|
+
raw, err := os.ReadFile(path)
|
|
57
|
+
if err != nil {
|
|
58
|
+
return Status{}, err
|
|
59
|
+
}
|
|
60
|
+
var status Status
|
|
61
|
+
if err := json.Unmarshal(raw, &status); err != nil {
|
|
62
|
+
return Status{}, err
|
|
63
|
+
}
|
|
64
|
+
return status, nil
|
|
65
|
+
}
|