@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.
Files changed (119) hide show
  1. package/.github/workflows/release.yml +44 -0
  2. package/.goreleaser.yml +44 -0
  3. package/AGENTS.md +60 -0
  4. package/CLAUDE.md +192 -0
  5. package/README.md +2 -0
  6. package/docs/architecture/awiki-command-v2.md +955 -0
  7. package/docs/architecture/awiki-skill-architecture.md +475 -0
  8. package/docs/architecture/awiki-v2-architecture.md +1063 -0
  9. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/cli-init.md +1008 -0
  10. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/output-format.md +407 -0
  11. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/overall-init.md +741 -0
  12. package/docs/harness/review-spec.md +474 -0
  13. package/docs/installation.md +372 -0
  14. package/docs/plan/awiki-v2-implementation-plan.md +903 -0
  15. package/docs/plan/phase-0/adr-index.md +56 -0
  16. package/docs/plan/phase-0/audit-findings.md +251 -0
  17. package/docs/plan/phase-0/capability-mapping.md +108 -0
  18. package/docs/plan/phase-0/implementation-constraints.md +363 -0
  19. package/docs/publish.md +169 -0
  20. package/go.mod +29 -0
  21. package/go.sum +73 -0
  22. package/internal/anpsdk/registry.go +63 -0
  23. package/internal/authsdk/session.go +351 -0
  24. package/internal/buildinfo/buildinfo.go +34 -0
  25. package/internal/cli/app.go +136 -0
  26. package/internal/cli/app_test.go +88 -0
  27. package/internal/cli/debug.go +104 -0
  28. package/internal/cli/group.go +263 -0
  29. package/internal/cli/id.go +473 -0
  30. package/internal/cli/init.go +134 -0
  31. package/internal/cli/msg.go +228 -0
  32. package/internal/cli/page.go +267 -0
  33. package/internal/cli/root.go +499 -0
  34. package/internal/cli/runtime.go +232 -0
  35. package/internal/cli/upgrade.go +60 -0
  36. package/internal/cmdmeta/catalog.go +203 -0
  37. package/internal/cmdmeta/catalog_test.go +21 -0
  38. package/internal/config/config.go +399 -0
  39. package/internal/config/config_test.go +104 -0
  40. package/internal/config/write.go +37 -0
  41. package/internal/content/service.go +314 -0
  42. package/internal/content/service_test.go +165 -0
  43. package/internal/content/types.go +44 -0
  44. package/internal/docs/topics.go +110 -0
  45. package/internal/doctor/doctor.go +306 -0
  46. package/internal/identity/client.go +267 -0
  47. package/internal/identity/did.go +85 -0
  48. package/internal/identity/did_test.go +50 -0
  49. package/internal/identity/layout.go +206 -0
  50. package/internal/identity/legacy.go +378 -0
  51. package/internal/identity/public.go +70 -0
  52. package/internal/identity/public_test.go +73 -0
  53. package/internal/identity/readiness.go +74 -0
  54. package/internal/identity/service.go +826 -0
  55. package/internal/identity/store.go +385 -0
  56. package/internal/identity/store_test.go +180 -0
  57. package/internal/identity/types.go +204 -0
  58. package/internal/message/auth.go +167 -0
  59. package/internal/message/group_service.go +838 -0
  60. package/internal/message/group_wire.go +350 -0
  61. package/internal/message/group_wire_test.go +67 -0
  62. package/internal/message/helpers.go +61 -0
  63. package/internal/message/http_client.go +334 -0
  64. package/internal/message/proof.go +156 -0
  65. package/internal/message/proof_test.go +61 -0
  66. package/internal/message/service.go +696 -0
  67. package/internal/message/service_test.go +97 -0
  68. package/internal/message/types.go +155 -0
  69. package/internal/message/wire.go +100 -0
  70. package/internal/message/wire_test.go +49 -0
  71. package/internal/message/ws_proxy_client.go +151 -0
  72. package/internal/output/output.go +350 -0
  73. package/internal/output/output_test.go +48 -0
  74. package/internal/runtime/config.go +117 -0
  75. package/internal/runtime/config_test.go +46 -0
  76. package/internal/runtime/listener/files.go +65 -0
  77. package/internal/runtime/listener/manager.go +142 -0
  78. package/internal/runtime/listener/server.go +983 -0
  79. package/internal/runtime/listener/server_test.go +319 -0
  80. package/internal/runtime/listener/sysproc_unix.go +17 -0
  81. package/internal/runtime/listener/sysproc_windows.go +13 -0
  82. package/internal/runtime/listener/types.go +21 -0
  83. package/internal/runtime/listener/wsclient.go +299 -0
  84. package/internal/runtime/listener/wsclient_test.go +41 -0
  85. package/internal/store/dao.go +632 -0
  86. package/internal/store/dao_test.go +87 -0
  87. package/internal/store/helpers.go +197 -0
  88. package/internal/store/import.go +499 -0
  89. package/internal/store/import_test.go +103 -0
  90. package/internal/store/open.go +71 -0
  91. package/internal/store/query.go +151 -0
  92. package/internal/store/schema.go +277 -0
  93. package/internal/store/schema_test.go +56 -0
  94. package/internal/store/types.go +177 -0
  95. package/internal/update/update.go +368 -0
  96. package/package.json +17 -0
  97. package/scripts/install.js +171 -0
  98. package/scripts/release/release-prerelease.sh +86 -0
  99. package/scripts/release/tag-release.sh +66 -0
  100. package/scripts/release/withdraw-release.sh +78 -0
  101. package/scripts/run.js +69 -0
  102. package/skills/README.md +32 -0
  103. package/skills/awiki-bundle/SKILL.md +76 -0
  104. package/skills/awiki-debug/SKILL.md +80 -0
  105. package/skills/awiki-group/SKILL.md +111 -0
  106. package/skills/awiki-id/SKILL.md +123 -0
  107. package/skills/awiki-msg/SKILL.md +131 -0
  108. package/skills/awiki-page/SKILL.md +93 -0
  109. package/skills/awiki-people/SKILL.md +66 -0
  110. package/skills/awiki-runtime/SKILL.md +137 -0
  111. package/skills/awiki-shared/SKILL.md +124 -0
  112. package/skills/awiki-workflow-discovery/SKILL.md +93 -0
  113. package/skills/awiki-workflow-onboarding/SKILL.md +119 -0
  114. package/skills/manifests/skills.yaml +260 -0
  115. package/skills/templates/bundle-skill-template.md +42 -0
  116. package/skills/templates/debug-skill-template.md +44 -0
  117. package/skills/templates/domain-skill-template.md +56 -0
  118. package/skills/templates/shared-skill-template.md +46 -0
  119. package/skills/templates/workflow-skill-template.md +46 -0
@@ -0,0 +1,499 @@
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "fmt"
7
+ "path/filepath"
8
+ "sort"
9
+ "strings"
10
+
11
+ appconfig "github.com/agentconnect/awiki-cli/internal/config"
12
+ "github.com/agentconnect/awiki-cli/internal/identity"
13
+ )
14
+
15
+ func ScanLegacyDatabase(ctx context.Context, paths appconfig.Paths) (*LegacyScan, error) {
16
+ legacyPath := strings.TrimSpace(paths.LegacyDataDir)
17
+ if strings.HasSuffix(strings.ToLower(legacyPath), ".db") {
18
+ legacyPath = legacyPath
19
+ } else {
20
+ legacyPath = filepath.Join(paths.LegacyDataDir, "database", "awiki.db")
21
+ }
22
+ scan := &LegacyScan{
23
+ Path: legacyPath,
24
+ }
25
+ db, err := OpenReadOnly(scan.Path)
26
+ if err != nil {
27
+ if strings.Contains(err.Error(), "no such file") || strings.Contains(err.Error(), "unable to open database file") {
28
+ return scan, nil
29
+ }
30
+ return nil, err
31
+ }
32
+ defer db.Close()
33
+ scan.Exists = true
34
+ version, err := schemaVersion(ctx, db)
35
+ if err != nil {
36
+ return nil, err
37
+ }
38
+ scan.SchemaVersion = version
39
+ rows, err := queryMaps(ctx, db, `SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
40
+ if err != nil {
41
+ return nil, err
42
+ }
43
+ for _, row := range rows {
44
+ if name, ok := row["name"].(string); ok {
45
+ scan.Tables = append(scan.Tables, name)
46
+ }
47
+ }
48
+ return scan, nil
49
+ }
50
+
51
+ func ImportLegacyDatabase(ctx context.Context, targetDB *sql.DB, paths appconfig.Paths, manager *identity.Manager) (*ImportReport, error) {
52
+ scan, err := ScanLegacyDatabase(ctx, paths)
53
+ if err != nil {
54
+ return nil, err
55
+ }
56
+ if !scan.Exists {
57
+ return nil, ErrLegacyDatabaseNotFound
58
+ }
59
+ legacyDB, err := OpenReadOnly(scan.Path)
60
+ if err != nil {
61
+ return nil, err
62
+ }
63
+ defer legacyDB.Close()
64
+ if err := EnsureSchema(ctx, targetDB); err != nil {
65
+ return nil, err
66
+ }
67
+ report := &ImportReport{
68
+ SourcePath: scan.Path,
69
+ SourceSchemaVersion: scan.SchemaVersion,
70
+ ImportedRows: map[string]int{},
71
+ }
72
+ ownerByCredential := map[string]string{}
73
+ defaultOwner := ""
74
+ if manager != nil {
75
+ if identities, listErr := manager.List(); listErr == nil {
76
+ for _, summary := range identities {
77
+ ownerByCredential[summary.IdentityName] = summary.DID
78
+ if summary.IsDefault {
79
+ defaultOwner = summary.DID
80
+ }
81
+ }
82
+ if defaultOwner == "" && len(identities) == 1 {
83
+ defaultOwner = identities[0].DID
84
+ }
85
+ }
86
+ }
87
+ if scan.SchemaVersion > 0 && scan.SchemaVersion < 6 && defaultOwner == "" {
88
+ return nil, fmt.Errorf("%w: legacy schema < 6 requires at least one imported identity so owner_did can be inferred", ErrUnsupportedLegacySchema)
89
+ }
90
+ importers := []struct {
91
+ name string
92
+ fn func(context.Context, *sql.DB, *sql.DB, map[string]string, string) (int, error)
93
+ }{
94
+ {name: "messages", fn: importMessages},
95
+ {name: "e2ee_outbox", fn: importE2EEOutbox},
96
+ {name: "contacts", fn: importContacts},
97
+ {name: "groups", fn: importGroups},
98
+ {name: "group_members", fn: importGroupMembers},
99
+ {name: "relationship_events", fn: importRelationshipEvents},
100
+ {name: "e2ee_sessions", fn: importE2EESessions},
101
+ }
102
+ for _, importer := range importers {
103
+ count, importErr := importer.fn(ctx, legacyDB, targetDB, ownerByCredential, defaultOwner)
104
+ if importErr != nil {
105
+ if errorsIsMissingTable(importErr) {
106
+ report.SkippedTables = append(report.SkippedTables, importer.name)
107
+ continue
108
+ }
109
+ return nil, fmt.Errorf("import %s: %w", importer.name, importErr)
110
+ }
111
+ report.ImportedRows[importer.name] = count
112
+ }
113
+ sort.Strings(report.SkippedTables)
114
+ return report, nil
115
+ }
116
+
117
+ func inferOwnerDID(row map[string]any, ownerByCredential map[string]string, defaultOwner string) string {
118
+ if owner, ok := row["owner_did"].(string); ok && strings.TrimSpace(owner) != "" {
119
+ return owner
120
+ }
121
+ if credential, ok := row["credential_name"].(string); ok && credential != "" {
122
+ if owner, ok := ownerByCredential[credential]; ok {
123
+ return owner
124
+ }
125
+ }
126
+ return defaultOwner
127
+ }
128
+
129
+ func importMessages(ctx context.Context, src, dst *sql.DB, ownerByCredential map[string]string, defaultOwner string) (int, error) {
130
+ rows, err := queryMaps(ctx, src, `SELECT * FROM messages`)
131
+ if err != nil {
132
+ return 0, err
133
+ }
134
+ count := 0
135
+ for _, row := range rows {
136
+ msgID := stringFromAny(row["msg_id"])
137
+ if msgID == "" {
138
+ continue
139
+ }
140
+ threadID := stringFromAny(row["thread_id"])
141
+ if threadID == "" {
142
+ threadID = MakeThreadID(inferOwnerDID(row, ownerByCredential, defaultOwner), stringFromAny(row["sender_did"]), stringFromAny(row["group_id"]))
143
+ }
144
+ record := MessageRecord{
145
+ MsgID: msgID,
146
+ OwnerDID: inferOwnerDID(row, ownerByCredential, defaultOwner),
147
+ ThreadID: threadID,
148
+ Direction: intFromAny(row["direction"]),
149
+ SenderDID: stringFromAny(row["sender_did"]),
150
+ ReceiverDID: stringFromAny(row["receiver_did"]),
151
+ GroupID: stringFromAny(row["group_id"]),
152
+ GroupDID: stringFromAny(row["group_did"]),
153
+ ContentType: defaultString(stringFromAny(row["content_type"]), "text"),
154
+ Content: stringFromAny(row["content"]),
155
+ Title: stringFromAny(row["title"]),
156
+ ServerSeq: int64PtrFromAny(row["server_seq"]),
157
+ SentAt: stringFromAny(row["sent_at"]),
158
+ IsE2EE: boolFromAny(row["is_e2ee"]),
159
+ IsRead: boolFromAny(row["is_read"]),
160
+ SenderName: stringFromAny(row["sender_name"]),
161
+ Metadata: metadataFromAny(row["metadata"]),
162
+ CredentialName: stringFromAny(row["credential_name"]),
163
+ }
164
+ if err := StoreMessage(ctx, dst, record); err != nil {
165
+ return 0, err
166
+ }
167
+ count++
168
+ }
169
+ return count, nil
170
+ }
171
+
172
+ func importE2EEOutbox(ctx context.Context, src, dst *sql.DB, ownerByCredential map[string]string, defaultOwner string) (int, error) {
173
+ rows, err := queryMaps(ctx, src, `SELECT * FROM e2ee_outbox`)
174
+ if err != nil {
175
+ return 0, err
176
+ }
177
+ count := 0
178
+ for _, row := range rows {
179
+ record := E2EEOutboxRecord{
180
+ OutboxID: stringFromAny(row["outbox_id"]),
181
+ OwnerDID: inferOwnerDID(row, ownerByCredential, defaultOwner),
182
+ PeerDID: stringFromAny(row["peer_did"]),
183
+ SessionID: stringFromAny(row["session_id"]),
184
+ OriginalType: defaultString(stringFromAny(row["original_type"]), "text"),
185
+ Plaintext: stringFromAny(row["plaintext"]),
186
+ LocalStatus: defaultString(stringFromAny(row["local_status"]), "queued"),
187
+ AttemptCount: intFromAny(row["attempt_count"]),
188
+ SentMsgID: stringFromAny(row["sent_msg_id"]),
189
+ SentServerSeq: int64PtrFromAny(row["sent_server_seq"]),
190
+ LastErrorCode: stringFromAny(row["last_error_code"]),
191
+ RetryHint: stringFromAny(row["retry_hint"]),
192
+ FailedMsgID: stringFromAny(row["failed_msg_id"]),
193
+ FailedServerSeq: int64PtrFromAny(row["failed_server_seq"]),
194
+ Metadata: metadataFromAny(row["metadata"]),
195
+ LastAttemptAt: stringFromAny(row["last_attempt_at"]),
196
+ CreatedAt: stringFromAny(row["created_at"]),
197
+ UpdatedAt: stringFromAny(row["updated_at"]),
198
+ CredentialName: stringFromAny(row["credential_name"]),
199
+ }
200
+ if _, err := QueueE2EEOutbox(ctx, dst, record); err != nil {
201
+ return 0, err
202
+ }
203
+ count++
204
+ }
205
+ return count, nil
206
+ }
207
+
208
+ func importContacts(ctx context.Context, src, dst *sql.DB, ownerByCredential map[string]string, defaultOwner string) (int, error) {
209
+ rows, err := queryMaps(ctx, src, `SELECT * FROM contacts`)
210
+ if err != nil {
211
+ return 0, err
212
+ }
213
+ count := 0
214
+ for _, row := range rows {
215
+ did := stringFromAny(row["did"])
216
+ if did == "" {
217
+ continue
218
+ }
219
+ record := ContactRecord{
220
+ OwnerDID: inferOwnerDID(row, ownerByCredential, defaultOwner),
221
+ DID: did,
222
+ Name: stringFromAny(row["name"]),
223
+ Handle: stringFromAny(row["handle"]),
224
+ NickName: stringFromAny(row["nick_name"]),
225
+ Bio: stringFromAny(row["bio"]),
226
+ ProfileMD: stringFromAny(row["profile_md"]),
227
+ Tags: stringFromAny(row["tags"]),
228
+ Relationship: stringFromAny(row["relationship"]),
229
+ SourceType: stringFromAny(row["source_type"]),
230
+ SourceName: stringFromAny(row["source_name"]),
231
+ SourceGroupID: stringFromAny(row["source_group_id"]),
232
+ ConnectedAt: stringFromAny(row["connected_at"]),
233
+ RecommendedReason: stringFromAny(row["recommended_reason"]),
234
+ Followed: boolPtrFromAny(row["followed"]),
235
+ Messaged: boolPtrFromAny(row["messaged"]),
236
+ Note: stringFromAny(row["note"]),
237
+ FirstSeenAt: stringFromAny(row["first_seen_at"]),
238
+ LastSeenAt: stringFromAny(row["last_seen_at"]),
239
+ Metadata: metadataFromAny(row["metadata"]),
240
+ }
241
+ if err := UpsertContact(ctx, dst, record); err != nil {
242
+ return 0, err
243
+ }
244
+ count++
245
+ }
246
+ return count, nil
247
+ }
248
+
249
+ func importGroups(ctx context.Context, src, dst *sql.DB, ownerByCredential map[string]string, defaultOwner string) (int, error) {
250
+ rows, err := queryMaps(ctx, src, `SELECT * FROM groups`)
251
+ if err != nil {
252
+ return 0, err
253
+ }
254
+ count := 0
255
+ for _, row := range rows {
256
+ groupID := stringFromAny(row["group_id"])
257
+ if groupID == "" {
258
+ continue
259
+ }
260
+ record := GroupRecord{
261
+ OwnerDID: inferOwnerDID(row, ownerByCredential, defaultOwner),
262
+ GroupID: groupID,
263
+ GroupDID: stringFromAny(row["group_did"]),
264
+ Name: stringFromAny(row["name"]),
265
+ GroupMode: defaultString(stringFromAny(row["group_mode"]), "general"),
266
+ Slug: stringFromAny(row["slug"]),
267
+ Description: stringFromAny(row["description"]),
268
+ Goal: stringFromAny(row["goal"]),
269
+ Rules: stringFromAny(row["rules"]),
270
+ MessagePrompt: stringFromAny(row["message_prompt"]),
271
+ DocURL: stringFromAny(row["doc_url"]),
272
+ GroupOwnerDID: stringFromAny(row["group_owner_did"]),
273
+ GroupOwnerHandle: stringFromAny(row["group_owner_handle"]),
274
+ MyRole: stringFromAny(row["my_role"]),
275
+ MembershipStatus: defaultString(stringFromAny(row["membership_status"]), "active"),
276
+ JoinEnabled: boolPtrFromAny(row["join_enabled"]),
277
+ JoinCode: stringFromAny(row["join_code"]),
278
+ JoinCodeExpiresAt: stringFromAny(row["join_code_expires_at"]),
279
+ MemberCount: int64PtrFromAny(row["member_count"]),
280
+ LastSyncedSeq: int64PtrFromAny(row["last_synced_seq"]),
281
+ LastReadSeq: int64PtrFromAny(row["last_read_seq"]),
282
+ LastMessageAt: stringFromAny(row["last_message_at"]),
283
+ RemoteCreatedAt: stringFromAny(row["remote_created_at"]),
284
+ RemoteUpdatedAt: stringFromAny(row["remote_updated_at"]),
285
+ Metadata: metadataFromAny(row["metadata"]),
286
+ CredentialName: stringFromAny(row["credential_name"]),
287
+ }
288
+ if err := UpsertGroup(ctx, dst, record); err != nil {
289
+ return 0, err
290
+ }
291
+ count++
292
+ }
293
+ return count, nil
294
+ }
295
+
296
+ func importGroupMembers(ctx context.Context, src, dst *sql.DB, ownerByCredential map[string]string, defaultOwner string) (int, error) {
297
+ rows, err := queryMaps(ctx, src, `SELECT * FROM group_members`)
298
+ if err != nil {
299
+ return 0, err
300
+ }
301
+ count := 0
302
+ for _, row := range rows {
303
+ record := GroupMemberRecord{
304
+ OwnerDID: inferOwnerDID(row, ownerByCredential, defaultOwner),
305
+ GroupID: stringFromAny(row["group_id"]),
306
+ UserID: stringFromAny(row["user_id"]),
307
+ MemberDID: stringFromAny(row["member_did"]),
308
+ MemberHandle: stringFromAny(row["member_handle"]),
309
+ ProfileURL: stringFromAny(row["profile_url"]),
310
+ Role: stringFromAny(row["role"]),
311
+ Status: defaultString(stringFromAny(row["status"]), "active"),
312
+ JoinedAt: stringFromAny(row["joined_at"]),
313
+ SentMessageCount: int64PtrFromAny(row["sent_message_count"]),
314
+ Metadata: metadataFromAny(row["metadata"]),
315
+ CredentialName: stringFromAny(row["credential_name"]),
316
+ }
317
+ if record.GroupID == "" || record.UserID == "" {
318
+ continue
319
+ }
320
+ if err := UpsertGroupMember(ctx, dst, record); err != nil {
321
+ return 0, err
322
+ }
323
+ count++
324
+ }
325
+ return count, nil
326
+ }
327
+
328
+ func importRelationshipEvents(ctx context.Context, src, dst *sql.DB, ownerByCredential map[string]string, defaultOwner string) (int, error) {
329
+ rows, err := queryMaps(ctx, src, `SELECT * FROM relationship_events`)
330
+ if err != nil {
331
+ return 0, err
332
+ }
333
+ count := 0
334
+ for _, row := range rows {
335
+ record := RelationshipEventRecord{
336
+ EventID: stringFromAny(row["event_id"]),
337
+ OwnerDID: inferOwnerDID(row, ownerByCredential, defaultOwner),
338
+ TargetDID: stringFromAny(row["target_did"]),
339
+ TargetHandle: stringFromAny(row["target_handle"]),
340
+ EventType: stringFromAny(row["event_type"]),
341
+ SourceType: stringFromAny(row["source_type"]),
342
+ SourceName: stringFromAny(row["source_name"]),
343
+ SourceGroupID: stringFromAny(row["source_group_id"]),
344
+ Reason: stringFromAny(row["reason"]),
345
+ Score: float64PtrFromAny(row["score"]),
346
+ Status: defaultString(stringFromAny(row["status"]), "pending"),
347
+ CreatedAt: stringFromAny(row["created_at"]),
348
+ UpdatedAt: stringFromAny(row["updated_at"]),
349
+ Metadata: metadataFromAny(row["metadata"]),
350
+ CredentialName: stringFromAny(row["credential_name"]),
351
+ }
352
+ if record.TargetDID == "" || record.EventType == "" {
353
+ continue
354
+ }
355
+ if _, err := AppendRelationshipEvent(ctx, dst, record); err != nil {
356
+ return 0, err
357
+ }
358
+ count++
359
+ }
360
+ return count, nil
361
+ }
362
+
363
+ func importE2EESessions(ctx context.Context, src, dst *sql.DB, ownerByCredential map[string]string, defaultOwner string) (int, error) {
364
+ rows, err := queryMaps(ctx, src, `SELECT * FROM e2ee_sessions`)
365
+ if err != nil {
366
+ return 0, err
367
+ }
368
+ count := 0
369
+ for _, row := range rows {
370
+ _, err := dst.ExecContext(ctx, `
371
+ INSERT OR REPLACE INTO e2ee_sessions
372
+ (owner_did, peer_did, session_id, is_initiator, send_chain_key, recv_chain_key,
373
+ send_seq, recv_seq, expires_at, created_at, active_at, peer_confirmed, credential_name, updated_at)
374
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
375
+ inferOwnerDID(row, ownerByCredential, defaultOwner),
376
+ stringFromAny(row["peer_did"]),
377
+ stringFromAny(row["session_id"]),
378
+ boolToInt(boolFromAny(row["is_initiator"])),
379
+ stringFromAny(row["send_chain_key"]),
380
+ stringFromAny(row["recv_chain_key"]),
381
+ intFromAny(row["send_seq"]),
382
+ intFromAny(row["recv_seq"]),
383
+ normalizeOptionalFloat64(float64PtrFromAny(row["expires_at"])),
384
+ stringFromAny(row["created_at"]),
385
+ normalizeOptionalString(stringFromAny(row["active_at"])),
386
+ boolToInt(boolFromAny(row["peer_confirmed"])),
387
+ stringFromAny(row["credential_name"]),
388
+ defaultString(stringFromAny(row["updated_at"]), nowUTC()),
389
+ )
390
+ if err != nil {
391
+ return 0, err
392
+ }
393
+ count++
394
+ }
395
+ return count, nil
396
+ }
397
+
398
+ func errorsIsMissingTable(err error) bool {
399
+ if err == nil {
400
+ return false
401
+ }
402
+ return strings.Contains(err.Error(), "no such table")
403
+ }
404
+
405
+ func boolFromAny(value any) bool {
406
+ switch typed := value.(type) {
407
+ case int64:
408
+ return typed != 0
409
+ case int:
410
+ return typed != 0
411
+ case float64:
412
+ return typed != 0
413
+ case bool:
414
+ return typed
415
+ case string:
416
+ return typed == "1" || strings.EqualFold(typed, "true")
417
+ default:
418
+ return false
419
+ }
420
+ }
421
+
422
+ func boolPtrFromAny(value any) *bool {
423
+ switch value.(type) {
424
+ case nil:
425
+ return nil
426
+ }
427
+ converted := boolFromAny(value)
428
+ return &converted
429
+ }
430
+
431
+ func intFromAny(value any) int {
432
+ switch typed := value.(type) {
433
+ case int:
434
+ return typed
435
+ case int64:
436
+ return int(typed)
437
+ case float64:
438
+ return int(typed)
439
+ case string:
440
+ if typed == "" {
441
+ return 0
442
+ }
443
+ var out int
444
+ fmt.Sscanf(typed, "%d", &out)
445
+ return out
446
+ default:
447
+ return 0
448
+ }
449
+ }
450
+
451
+ func int64PtrFromAny(value any) *int64 {
452
+ switch typed := value.(type) {
453
+ case nil:
454
+ return nil
455
+ case int64:
456
+ v := typed
457
+ return &v
458
+ case int:
459
+ v := int64(typed)
460
+ return &v
461
+ case float64:
462
+ v := int64(typed)
463
+ return &v
464
+ case string:
465
+ if strings.TrimSpace(typed) == "" {
466
+ return nil
467
+ }
468
+ var out int64
469
+ fmt.Sscanf(typed, "%d", &out)
470
+ return &out
471
+ default:
472
+ return nil
473
+ }
474
+ }
475
+
476
+ func float64PtrFromAny(value any) *float64 {
477
+ switch typed := value.(type) {
478
+ case nil:
479
+ return nil
480
+ case float64:
481
+ v := typed
482
+ return &v
483
+ case int64:
484
+ v := float64(typed)
485
+ return &v
486
+ case int:
487
+ v := float64(typed)
488
+ return &v
489
+ case string:
490
+ if strings.TrimSpace(typed) == "" {
491
+ return nil
492
+ }
493
+ var out float64
494
+ fmt.Sscanf(typed, "%f", &out)
495
+ return &out
496
+ default:
497
+ return nil
498
+ }
499
+ }
@@ -0,0 +1,103 @@
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "os"
7
+ "path/filepath"
8
+ "testing"
9
+
10
+ appconfig "github.com/agentconnect/awiki-cli/internal/config"
11
+ "github.com/agentconnect/awiki-cli/internal/identity"
12
+ )
13
+
14
+ func pathOverridePaths(path string) appconfig.Paths {
15
+ return appconfig.Paths{DatabaseFile: path}
16
+ }
17
+
18
+ func TestImportLegacyDatabaseV11(t *testing.T) {
19
+ t.Parallel()
20
+
21
+ root := t.TempDir()
22
+ targetPaths := appconfig.Paths{
23
+ DatabaseFile: filepath.Join(root, "current.db"),
24
+ IdentityDir: filepath.Join(root, "identities"),
25
+ LegacyCredentialsDir: filepath.Join(root, "legacy-credentials"),
26
+ LegacyDataDir: filepath.Join(root, "legacy-data"),
27
+ }
28
+ legacyDBPath := filepath.Join(targetPaths.LegacyDataDir, "database", "awiki.db")
29
+ if err := os.MkdirAll(filepath.Dir(legacyDBPath), 0o700); err != nil {
30
+ t.Fatalf("MkdirAll() error = %v", err)
31
+ }
32
+
33
+ legacyDB, err := Open(pathOverridePaths(legacyDBPath))
34
+ if err != nil {
35
+ t.Fatalf("Open(legacy) error = %v", err)
36
+ }
37
+ defer legacyDB.Close()
38
+ if err := EnsureSchema(context.Background(), legacyDB); err != nil {
39
+ t.Fatalf("EnsureSchema(legacy) error = %v", err)
40
+ }
41
+ if err := StoreMessage(context.Background(), legacyDB, MessageRecord{
42
+ MsgID: "legacy-msg",
43
+ OwnerDID: "did:wba:awiki.ai:user:legacy",
44
+ ThreadID: MakeThreadID("did:wba:awiki.ai:user:legacy", "did:wba:awiki.ai:user:peer", ""),
45
+ Direction: 0,
46
+ SenderDID: "did:wba:awiki.ai:user:peer",
47
+ ReceiverDID: "did:wba:awiki.ai:user:legacy",
48
+ ContentType: "text",
49
+ Content: "legacy hello",
50
+ CredentialName: "legacy",
51
+ }); err != nil {
52
+ t.Fatalf("StoreMessage(legacy) error = %v", err)
53
+ }
54
+ if err := UpsertContact(context.Background(), legacyDB, ContactRecord{
55
+ OwnerDID: "did:wba:awiki.ai:user:legacy",
56
+ DID: "did:wba:awiki.ai:user:peer",
57
+ Name: "Legacy Peer",
58
+ }); err != nil {
59
+ t.Fatalf("UpsertContact(legacy) error = %v", err)
60
+ }
61
+
62
+ manager := identity.NewManager(targetPaths)
63
+ generated, err := identity.GenerateIdentity(identity.GenerateOptions{
64
+ Hostname: "awiki.ai",
65
+ PathPrefix: []string{"user"},
66
+ ProofDomain: "awiki.ai",
67
+ })
68
+ if err != nil {
69
+ t.Fatalf("GenerateIdentity() error = %v", err)
70
+ }
71
+ if _, err := manager.Save(identity.SaveInput{
72
+ IdentityName: "legacy",
73
+ DID: "did:wba:awiki.ai:user:legacy",
74
+ UniqueID: generated.UniqueID,
75
+ DisplayName: "Legacy",
76
+ DIDDocument: generated.DIDDocument,
77
+ Key1PrivatePEM: generated.Key1PrivatePEM,
78
+ Key1PublicPEM: generated.Key1PublicPEM,
79
+ }); err != nil {
80
+ t.Fatalf("Save() error = %v", err)
81
+ }
82
+
83
+ targetDB, err := Open(targetPaths)
84
+ if err != nil {
85
+ t.Fatalf("Open(target) error = %v", err)
86
+ }
87
+ defer targetDB.Close()
88
+ report, err := ImportLegacyDatabase(context.Background(), targetDB, targetPaths, manager)
89
+ if err != nil {
90
+ t.Fatalf("ImportLegacyDatabase() error = %v", err)
91
+ }
92
+ if report.ImportedRows["messages"] != 1 || report.ImportedRows["contacts"] != 1 {
93
+ raw, _ := json.MarshalIndent(report, "", " ")
94
+ t.Fatalf("unexpected import report: %s", raw)
95
+ }
96
+ row, err := GetMessageByID(context.Background(), targetDB, "legacy-msg", "did:wba:awiki.ai:user:legacy", "")
97
+ if err != nil {
98
+ t.Fatalf("GetMessageByID(target) error = %v", err)
99
+ }
100
+ if row["content"] != "legacy hello" {
101
+ t.Fatalf("unexpected imported message: %#v", row)
102
+ }
103
+ }
@@ -0,0 +1,71 @@
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "time"
10
+
11
+ appconfig "github.com/agentconnect/awiki-cli/internal/config"
12
+ _ "modernc.org/sqlite"
13
+ )
14
+
15
+ const sqliteDriverName = "sqlite"
16
+
17
+ func Open(paths appconfig.Paths) (*sql.DB, error) {
18
+ return openDatabase(paths.DatabaseFile, OpenOptions{})
19
+ }
20
+
21
+ func OpenReadOnly(path string) (*sql.DB, error) {
22
+ return openDatabase(path, OpenOptions{ReadOnly: true})
23
+ }
24
+
25
+ func openDatabase(path string, options OpenOptions) (*sql.DB, error) {
26
+ if path == "" {
27
+ return nil, fmt.Errorf("sqlite path is required")
28
+ }
29
+ if !options.ReadOnly {
30
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
31
+ return nil, fmt.Errorf("create sqlite dir: %w", err)
32
+ }
33
+ }
34
+ dsn := path
35
+ if options.ReadOnly {
36
+ dsn = fmt.Sprintf("file:%s?mode=ro", path)
37
+ }
38
+ db, err := sql.Open(sqliteDriverName, dsn)
39
+ if err != nil {
40
+ return nil, fmt.Errorf("open sqlite database: %w", err)
41
+ }
42
+ db.SetMaxOpenConns(1)
43
+ db.SetConnMaxIdleTime(time.Minute)
44
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
45
+ defer cancel()
46
+ if err := db.PingContext(ctx); err != nil {
47
+ _ = db.Close()
48
+ return nil, fmt.Errorf("ping sqlite database: %w", err)
49
+ }
50
+ if !options.ReadOnly {
51
+ if err := configureDatabase(ctx, db); err != nil {
52
+ _ = db.Close()
53
+ return nil, err
54
+ }
55
+ }
56
+ return db, nil
57
+ }
58
+
59
+ func configureDatabase(ctx context.Context, db *sql.DB) error {
60
+ pragmas := []string{
61
+ "PRAGMA journal_mode=WAL;",
62
+ "PRAGMA foreign_keys=ON;",
63
+ "PRAGMA busy_timeout=5000;",
64
+ }
65
+ for _, pragma := range pragmas {
66
+ if _, err := db.ExecContext(ctx, pragma); err != nil {
67
+ return fmt.Errorf("configure sqlite (%s): %w", pragma, err)
68
+ }
69
+ }
70
+ return nil
71
+ }