skyboard-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/README.md +148 -0
- package/bin/sb.js +2 -0
- package/package.json +32 -0
- package/src/__tests__/auth-state.test.ts +111 -0
- package/src/__tests__/card-ref.test.ts +42 -0
- package/src/__tests__/column-match.test.ts +57 -0
- package/src/__tests__/commands.test.ts +326 -0
- package/src/__tests__/helpers.ts +154 -0
- package/src/__tests__/materialize.test.ts +257 -0
- package/src/__tests__/pds.test.ts +213 -0
- package/src/commands/add.ts +47 -0
- package/src/commands/boards.ts +64 -0
- package/src/commands/cards.ts +65 -0
- package/src/commands/cols.ts +51 -0
- package/src/commands/comment.ts +64 -0
- package/src/commands/edit.ts +89 -0
- package/src/commands/login.ts +19 -0
- package/src/commands/logout.ts +14 -0
- package/src/commands/mv.ts +84 -0
- package/src/commands/new.ts +80 -0
- package/src/commands/rm.ts +79 -0
- package/src/commands/show.ts +100 -0
- package/src/commands/status.ts +78 -0
- package/src/commands/use.ts +72 -0
- package/src/commands/whoami.ts +22 -0
- package/src/index.ts +143 -0
- package/src/lib/auth.ts +244 -0
- package/src/lib/card-ref.ts +32 -0
- package/src/lib/column-match.ts +45 -0
- package/src/lib/config.ts +182 -0
- package/src/lib/display.ts +112 -0
- package/src/lib/materialize.ts +138 -0
- package/src/lib/pds.ts +440 -0
- package/src/lib/permissions.ts +28 -0
- package/src/lib/schemas.ts +97 -0
- package/src/lib/tid.ts +22 -0
- package/src/lib/types.ts +144 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { materializeTasks } from "../lib/materialize.js";
|
|
3
|
+
import {
|
|
4
|
+
OWNER_DID,
|
|
5
|
+
TRUSTED_DID,
|
|
6
|
+
USER_DID,
|
|
7
|
+
UNTRUSTED_DID,
|
|
8
|
+
BOARD_URI,
|
|
9
|
+
TASK_RKEY_1,
|
|
10
|
+
TASK_RKEY_2,
|
|
11
|
+
TASK_URI_1,
|
|
12
|
+
TASK_URI_2,
|
|
13
|
+
makeTask,
|
|
14
|
+
makeOp,
|
|
15
|
+
} from "./helpers.js";
|
|
16
|
+
|
|
17
|
+
describe("materializeTasks", () => {
|
|
18
|
+
const trustedDids = new Set([TRUSTED_DID]);
|
|
19
|
+
|
|
20
|
+
it("returns a materialized task with no ops applied", () => {
|
|
21
|
+
const task = makeTask();
|
|
22
|
+
const result = materializeTasks([task], [], trustedDids, USER_DID, OWNER_DID);
|
|
23
|
+
|
|
24
|
+
expect(result).toHaveLength(1);
|
|
25
|
+
expect(result[0].effectiveTitle).toBe("Test Task");
|
|
26
|
+
expect(result[0].effectiveColumnId).toBe("col-todo");
|
|
27
|
+
expect(result[0].effectiveDescription).toBe("A task for testing");
|
|
28
|
+
expect(result[0].appliedOps).toHaveLength(0);
|
|
29
|
+
expect(result[0].sourceTask).toBe(task);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("applies owner ops (per-field LWW)", () => {
|
|
33
|
+
const task = makeTask({ createdAt: "2025-01-01T00:00:00.000Z" });
|
|
34
|
+
const op = makeOp({
|
|
35
|
+
did: OWNER_DID,
|
|
36
|
+
targetTaskUri: TASK_URI_1,
|
|
37
|
+
fields: { title: "Updated Title" },
|
|
38
|
+
createdAt: "2025-01-02T00:00:00.000Z",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const result = materializeTasks([task], [op], trustedDids, USER_DID, OWNER_DID);
|
|
42
|
+
|
|
43
|
+
expect(result[0].effectiveTitle).toBe("Updated Title");
|
|
44
|
+
expect(result[0].appliedOps).toHaveLength(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("applies trusted user ops", () => {
|
|
48
|
+
const task = makeTask({ createdAt: "2025-01-01T00:00:00.000Z" });
|
|
49
|
+
const op = makeOp({
|
|
50
|
+
did: TRUSTED_DID,
|
|
51
|
+
targetTaskUri: TASK_URI_1,
|
|
52
|
+
fields: { columnId: "col-done" },
|
|
53
|
+
createdAt: "2025-01-02T00:00:00.000Z",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const result = materializeTasks([task], [op], trustedDids, USER_DID, OWNER_DID);
|
|
57
|
+
|
|
58
|
+
expect(result[0].effectiveColumnId).toBe("col-done");
|
|
59
|
+
expect(result[0].appliedOps).toHaveLength(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("applies current user ops (even if not trusted)", () => {
|
|
63
|
+
const task = makeTask({ createdAt: "2025-01-01T00:00:00.000Z" });
|
|
64
|
+
const op = makeOp({
|
|
65
|
+
did: USER_DID,
|
|
66
|
+
targetTaskUri: TASK_URI_1,
|
|
67
|
+
fields: { title: "My Edit" },
|
|
68
|
+
createdAt: "2025-01-02T00:00:00.000Z",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const result = materializeTasks([task], [op], trustedDids, USER_DID, OWNER_DID);
|
|
72
|
+
|
|
73
|
+
expect(result[0].effectiveTitle).toBe("My Edit");
|
|
74
|
+
expect(result[0].appliedOps).toHaveLength(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("filters out untrusted ops", () => {
|
|
78
|
+
const task = makeTask({ createdAt: "2025-01-01T00:00:00.000Z" });
|
|
79
|
+
const op = makeOp({
|
|
80
|
+
did: UNTRUSTED_DID,
|
|
81
|
+
targetTaskUri: TASK_URI_1,
|
|
82
|
+
fields: { title: "Hacked Title" },
|
|
83
|
+
createdAt: "2025-01-02T00:00:00.000Z",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const result = materializeTasks([task], [op], trustedDids, USER_DID, OWNER_DID);
|
|
87
|
+
|
|
88
|
+
expect(result[0].effectiveTitle).toBe("Test Task"); // unchanged
|
|
89
|
+
expect(result[0].appliedOps).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("resolves per-field LWW independently", () => {
|
|
93
|
+
const task = makeTask({
|
|
94
|
+
title: "Original",
|
|
95
|
+
description: "Original desc",
|
|
96
|
+
createdAt: "2025-01-01T00:00:00.000Z",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const op1 = makeOp({
|
|
100
|
+
rkey: "op1",
|
|
101
|
+
did: OWNER_DID,
|
|
102
|
+
targetTaskUri: TASK_URI_1,
|
|
103
|
+
fields: { title: "Title v2" },
|
|
104
|
+
createdAt: "2025-01-02T00:00:00.000Z",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const op2 = makeOp({
|
|
108
|
+
rkey: "op2",
|
|
109
|
+
did: OWNER_DID,
|
|
110
|
+
targetTaskUri: TASK_URI_1,
|
|
111
|
+
fields: { description: "Desc v2" },
|
|
112
|
+
createdAt: "2025-01-03T00:00:00.000Z",
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const op3 = makeOp({
|
|
116
|
+
rkey: "op3",
|
|
117
|
+
did: OWNER_DID,
|
|
118
|
+
targetTaskUri: TASK_URI_1,
|
|
119
|
+
fields: { title: "Title v3" },
|
|
120
|
+
createdAt: "2025-01-04T00:00:00.000Z",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const result = materializeTasks(
|
|
124
|
+
[task],
|
|
125
|
+
[op1, op2, op3],
|
|
126
|
+
trustedDids,
|
|
127
|
+
USER_DID,
|
|
128
|
+
OWNER_DID,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(result[0].effectiveTitle).toBe("Title v3");
|
|
132
|
+
expect(result[0].effectiveDescription).toBe("Desc v2");
|
|
133
|
+
expect(result[0].appliedOps).toHaveLength(3);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("latest op wins (LWW) even if received out of order", () => {
|
|
137
|
+
const task = makeTask({ createdAt: "2025-01-01T00:00:00.000Z" });
|
|
138
|
+
|
|
139
|
+
const earlyOp = makeOp({
|
|
140
|
+
rkey: "early",
|
|
141
|
+
did: OWNER_DID,
|
|
142
|
+
targetTaskUri: TASK_URI_1,
|
|
143
|
+
fields: { title: "Early" },
|
|
144
|
+
createdAt: "2025-01-02T00:00:00.000Z",
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const lateOp = makeOp({
|
|
148
|
+
rkey: "late",
|
|
149
|
+
did: OWNER_DID,
|
|
150
|
+
targetTaskUri: TASK_URI_1,
|
|
151
|
+
fields: { title: "Late" },
|
|
152
|
+
createdAt: "2025-01-03T00:00:00.000Z",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Pass late op first — should still resolve to "Late"
|
|
156
|
+
const result = materializeTasks(
|
|
157
|
+
[task],
|
|
158
|
+
[lateOp, earlyOp],
|
|
159
|
+
trustedDids,
|
|
160
|
+
USER_DID,
|
|
161
|
+
OWNER_DID,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(result[0].effectiveTitle).toBe("Late");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("does not apply op older than the task creation", () => {
|
|
168
|
+
const task = makeTask({ createdAt: "2025-01-05T00:00:00.000Z" });
|
|
169
|
+
const op = makeOp({
|
|
170
|
+
did: OWNER_DID,
|
|
171
|
+
targetTaskUri: TASK_URI_1,
|
|
172
|
+
fields: { title: "Old Op" },
|
|
173
|
+
createdAt: "2025-01-01T00:00:00.000Z", // before task creation
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const result = materializeTasks([task], [op], trustedDids, USER_DID, OWNER_DID);
|
|
177
|
+
|
|
178
|
+
expect(result[0].effectiveTitle).toBe("Test Task"); // base task wins
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("deduplicates tasks with same did:rkey", () => {
|
|
182
|
+
const task = makeTask();
|
|
183
|
+
const dupe = makeTask(); // same rkey+did
|
|
184
|
+
|
|
185
|
+
const result = materializeTasks([task, dupe], [], trustedDids, USER_DID, OWNER_DID);
|
|
186
|
+
|
|
187
|
+
expect(result).toHaveLength(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("handles multiple tasks with separate ops", () => {
|
|
191
|
+
const task1 = makeTask({ rkey: TASK_RKEY_1 });
|
|
192
|
+
const task2 = makeTask({ rkey: TASK_RKEY_2, title: "Task 2" });
|
|
193
|
+
|
|
194
|
+
const op1 = makeOp({
|
|
195
|
+
rkey: "op-for-t1",
|
|
196
|
+
did: OWNER_DID,
|
|
197
|
+
targetTaskUri: TASK_URI_1,
|
|
198
|
+
fields: { title: "Task 1 Updated" },
|
|
199
|
+
createdAt: "2025-01-02T00:00:00.000Z",
|
|
200
|
+
});
|
|
201
|
+
const op2 = makeOp({
|
|
202
|
+
rkey: "op-for-t2",
|
|
203
|
+
did: OWNER_DID,
|
|
204
|
+
targetTaskUri: TASK_URI_2,
|
|
205
|
+
fields: { title: "Task 2 Updated" },
|
|
206
|
+
createdAt: "2025-01-02T00:00:00.000Z",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const result = materializeTasks(
|
|
210
|
+
[task1, task2],
|
|
211
|
+
[op1, op2],
|
|
212
|
+
trustedDids,
|
|
213
|
+
USER_DID,
|
|
214
|
+
OWNER_DID,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
expect(result).toHaveLength(2);
|
|
218
|
+
expect(result[0].effectiveTitle).toBe("Task 1 Updated");
|
|
219
|
+
expect(result[1].effectiveTitle).toBe("Task 2 Updated");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("tracks lastModifiedBy and lastModifiedAt", () => {
|
|
223
|
+
const task = makeTask({ createdAt: "2025-01-01T00:00:00.000Z" });
|
|
224
|
+
const op = makeOp({
|
|
225
|
+
did: TRUSTED_DID,
|
|
226
|
+
targetTaskUri: TASK_URI_1,
|
|
227
|
+
fields: { title: "Trusted Edit" },
|
|
228
|
+
createdAt: "2025-01-05T00:00:00.000Z",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const result = materializeTasks([task], [op], trustedDids, USER_DID, OWNER_DID);
|
|
232
|
+
|
|
233
|
+
expect(result[0].lastModifiedBy).toBe(TRUSTED_DID);
|
|
234
|
+
expect(result[0].lastModifiedAt).toBe("2025-01-05T00:00:00.000Z");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("applies task author ops on their own task", () => {
|
|
238
|
+
const task = makeTask({
|
|
239
|
+
did: USER_DID,
|
|
240
|
+
rkey: TASK_RKEY_1,
|
|
241
|
+
createdAt: "2025-01-01T00:00:00.000Z",
|
|
242
|
+
});
|
|
243
|
+
// The task URI for user-owned task
|
|
244
|
+
const taskUri = `at://${USER_DID}/dev.skyboard.task/${TASK_RKEY_1}`;
|
|
245
|
+
const op = makeOp({
|
|
246
|
+
did: USER_DID,
|
|
247
|
+
targetTaskUri: taskUri,
|
|
248
|
+
fields: { title: "Author Edit" },
|
|
249
|
+
createdAt: "2025-01-02T00:00:00.000Z",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const result = materializeTasks([task], [op], trustedDids, "did:plc:somebodyelse", OWNER_DID);
|
|
253
|
+
|
|
254
|
+
expect(result[0].effectiveTitle).toBe("Author Edit");
|
|
255
|
+
expect(result[0].appliedOps).toHaveLength(1);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
OWNER_DID,
|
|
4
|
+
USER_DID,
|
|
5
|
+
BOARD_RKEY,
|
|
6
|
+
BOARD_URI,
|
|
7
|
+
TASK_RKEY_1,
|
|
8
|
+
PDS_ENDPOINT,
|
|
9
|
+
COLUMNS,
|
|
10
|
+
makePLCResponse,
|
|
11
|
+
makeListRecordsResponse,
|
|
12
|
+
} from "./helpers.js";
|
|
13
|
+
|
|
14
|
+
// We need to reset the pdsCache between tests since it's a module-level Map.
|
|
15
|
+
// The simplest approach: re-import fresh each time via dynamic import + vi.resetModules.
|
|
16
|
+
|
|
17
|
+
let resolvePDS: typeof import("../lib/pds.js").resolvePDS;
|
|
18
|
+
let fetchBoard: typeof import("../lib/pds.js").fetchBoard;
|
|
19
|
+
let resolveHandle: typeof import("../lib/pds.js").resolveHandle;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
vi.resetModules();
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
|
|
25
|
+
const mod = await import("../lib/pds.js");
|
|
26
|
+
resolvePDS = mod.resolvePDS;
|
|
27
|
+
fetchBoard = mod.fetchBoard;
|
|
28
|
+
resolveHandle = mod.resolveHandle;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function stubFetch(handler: (url: string) => Response | Promise<Response>) {
|
|
32
|
+
const mockFetch = vi.fn(async (input: string | URL | Request) => {
|
|
33
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
34
|
+
return handler(url);
|
|
35
|
+
});
|
|
36
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
37
|
+
return mockFetch;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
41
|
+
return new Response(JSON.stringify(data), {
|
|
42
|
+
status,
|
|
43
|
+
headers: { "Content-Type": "application/json" },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function textResponse(text: string, status = 200): Response {
|
|
48
|
+
return new Response(text, { status });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("resolvePDS", () => {
|
|
52
|
+
it("resolves a did:plc via plc.directory", async () => {
|
|
53
|
+
stubFetch((url) => {
|
|
54
|
+
if (url.includes("plc.directory")) {
|
|
55
|
+
return jsonResponse(makePLCResponse(OWNER_DID, PDS_ENDPOINT));
|
|
56
|
+
}
|
|
57
|
+
return new Response("", { status: 404 });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const result = await resolvePDS(OWNER_DID);
|
|
61
|
+
expect(result).toBe(PDS_ENDPOINT);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("resolves a did:web via .well-known/did.json", async () => {
|
|
65
|
+
const webDid = "did:web:example.com";
|
|
66
|
+
stubFetch((url) => {
|
|
67
|
+
if (url.includes("example.com/.well-known/did.json")) {
|
|
68
|
+
return jsonResponse(makePLCResponse(webDid, PDS_ENDPOINT));
|
|
69
|
+
}
|
|
70
|
+
return new Response("", { status: 404 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const result = await resolvePDS(webDid);
|
|
74
|
+
expect(result).toBe(PDS_ENDPOINT);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns null for unknown DID method", async () => {
|
|
78
|
+
stubFetch(() => new Response("", { status: 404 }));
|
|
79
|
+
const result = await resolvePDS("did:key:z12345");
|
|
80
|
+
expect(result).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns null on fetch failure", async () => {
|
|
84
|
+
stubFetch(() => new Response("", { status: 500 }));
|
|
85
|
+
const result = await resolvePDS(OWNER_DID);
|
|
86
|
+
expect(result).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("caches resolved PDS endpoints", async () => {
|
|
90
|
+
const mockFetch = stubFetch((url) => {
|
|
91
|
+
if (url.includes("plc.directory")) {
|
|
92
|
+
return jsonResponse(makePLCResponse(OWNER_DID, PDS_ENDPOINT));
|
|
93
|
+
}
|
|
94
|
+
return new Response("", { status: 404 });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await resolvePDS(OWNER_DID);
|
|
98
|
+
await resolvePDS(OWNER_DID);
|
|
99
|
+
|
|
100
|
+
// Should only call fetch once (second call uses cache)
|
|
101
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("fetchBoard", () => {
|
|
106
|
+
it("fetches and parses a board record", async () => {
|
|
107
|
+
const boardRecord = {
|
|
108
|
+
$type: "dev.skyboard.board",
|
|
109
|
+
name: "Test Board",
|
|
110
|
+
description: "A test board",
|
|
111
|
+
columns: COLUMNS,
|
|
112
|
+
createdAt: "2025-01-01T00:00:00.000Z",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
stubFetch((url) => {
|
|
116
|
+
if (url.includes("plc.directory")) {
|
|
117
|
+
return jsonResponse(makePLCResponse(OWNER_DID, PDS_ENDPOINT));
|
|
118
|
+
}
|
|
119
|
+
if (url.includes("getRecord")) {
|
|
120
|
+
return jsonResponse({ uri: `at://${OWNER_DID}/dev.skyboard.board/${BOARD_RKEY}`, value: boardRecord });
|
|
121
|
+
}
|
|
122
|
+
return new Response("", { status: 404 });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const board = await fetchBoard(OWNER_DID, BOARD_RKEY);
|
|
126
|
+
expect(board).not.toBeNull();
|
|
127
|
+
expect(board!.name).toBe("Test Board");
|
|
128
|
+
expect(board!.columns).toEqual(COLUMNS);
|
|
129
|
+
expect(board!.did).toBe(OWNER_DID);
|
|
130
|
+
expect(board!.rkey).toBe(BOARD_RKEY);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns null when board not found", async () => {
|
|
134
|
+
stubFetch((url) => {
|
|
135
|
+
if (url.includes("plc.directory")) {
|
|
136
|
+
return jsonResponse(makePLCResponse(OWNER_DID, PDS_ENDPOINT));
|
|
137
|
+
}
|
|
138
|
+
return new Response("", { status: 404 });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const board = await fetchBoard(OWNER_DID, "nonexistent");
|
|
142
|
+
expect(board).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns null when PDS cannot be resolved", async () => {
|
|
146
|
+
stubFetch(() => new Response("", { status: 500 }));
|
|
147
|
+
const board = await fetchBoard(OWNER_DID, BOARD_RKEY);
|
|
148
|
+
expect(board).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("resolveHandle", () => {
|
|
153
|
+
it("resolves via .well-known/atproto-did", async () => {
|
|
154
|
+
stubFetch((url) => {
|
|
155
|
+
if (url.includes(".well-known/atproto-did")) {
|
|
156
|
+
return textResponse(OWNER_DID);
|
|
157
|
+
}
|
|
158
|
+
return new Response("", { status: 404 });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const result = await resolveHandle("alice.example.com");
|
|
162
|
+
expect(result).toBe(OWNER_DID);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("falls back to bsky.social resolution", async () => {
|
|
166
|
+
stubFetch((url) => {
|
|
167
|
+
if (url.includes(".well-known/atproto-did")) {
|
|
168
|
+
return new Response("", { status: 404 });
|
|
169
|
+
}
|
|
170
|
+
if (url.includes("bsky.social/xrpc/com.atproto.identity.resolveHandle")) {
|
|
171
|
+
return jsonResponse({ did: USER_DID });
|
|
172
|
+
}
|
|
173
|
+
return new Response("", { status: 404 });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const result = await resolveHandle("bob.bsky.social");
|
|
177
|
+
expect(result).toBe(USER_DID);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("returns null when all resolution methods fail", async () => {
|
|
181
|
+
stubFetch(() => new Response("", { status: 404 }));
|
|
182
|
+
|
|
183
|
+
const result = await resolveHandle("nobody.example.com");
|
|
184
|
+
expect(result).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("trims whitespace from .well-known response", async () => {
|
|
188
|
+
stubFetch((url) => {
|
|
189
|
+
if (url.includes(".well-known/atproto-did")) {
|
|
190
|
+
return textResponse(` ${OWNER_DID} \n`);
|
|
191
|
+
}
|
|
192
|
+
return new Response("", { status: 404 });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const result = await resolveHandle("alice.example.com");
|
|
196
|
+
expect(result).toBe(OWNER_DID);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("rejects .well-known response that is not a DID", async () => {
|
|
200
|
+
stubFetch((url) => {
|
|
201
|
+
if (url.includes(".well-known/atproto-did")) {
|
|
202
|
+
return textResponse("not-a-did");
|
|
203
|
+
}
|
|
204
|
+
if (url.includes("bsky.social")) {
|
|
205
|
+
return new Response("", { status: 404 });
|
|
206
|
+
}
|
|
207
|
+
return new Response("", { status: 404 });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const result = await resolveHandle("alice.example.com");
|
|
211
|
+
expect(result).toBeNull();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { requireAgent } from "../lib/auth.js";
|
|
2
|
+
import { fetchBoard } from "../lib/pds.js";
|
|
3
|
+
import { addKnownBoard } from "../lib/config.js";
|
|
4
|
+
import { BOARD_COLLECTION } from "../lib/tid.js";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
|
|
7
|
+
export async function addCommand(link: string): Promise<void> {
|
|
8
|
+
await requireAgent();
|
|
9
|
+
|
|
10
|
+
let boardDid: string | undefined;
|
|
11
|
+
let boardRkey: string | undefined;
|
|
12
|
+
|
|
13
|
+
// Parse AT URI
|
|
14
|
+
if (link.startsWith("at://")) {
|
|
15
|
+
const parts = link.replace("at://", "").split("/");
|
|
16
|
+
if (parts.length >= 3 && parts[1] === BOARD_COLLECTION) {
|
|
17
|
+
boardDid = parts[0];
|
|
18
|
+
boardRkey = parts[2];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Parse web URL
|
|
23
|
+
if (!boardDid && (link.startsWith("http://") || link.startsWith("https://"))) {
|
|
24
|
+
const url = new URL(link);
|
|
25
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
26
|
+
if (pathParts.length >= 3 && pathParts[0] === "board") {
|
|
27
|
+
boardDid = pathParts[1];
|
|
28
|
+
boardRkey = pathParts[2];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!boardDid || !boardRkey) {
|
|
33
|
+
console.error(chalk.red("Could not parse board link. Provide an AT URI or web URL."));
|
|
34
|
+
console.error(chalk.dim(" AT URI: at://did:plc:xxx/dev.skyboard.board/rkey"));
|
|
35
|
+
console.error(chalk.dim(" URL: https://skyboard.dev/board/did:plc:xxx/rkey"));
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const board = await fetchBoard(boardDid, boardRkey);
|
|
40
|
+
if (!board) {
|
|
41
|
+
console.error(chalk.red("Board not found."));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
addKnownBoard({ did: boardDid, rkey: boardRkey, name: board.name });
|
|
46
|
+
console.log(chalk.green(`Added board: ${chalk.bold(board.name)}`));
|
|
47
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { requireAgent } from "../lib/auth.js";
|
|
2
|
+
import { fetchMyBoards, fetchBoardData } from "../lib/pds.js";
|
|
3
|
+
import { loadConfig, getDefaultBoard } from "../lib/config.js";
|
|
4
|
+
import { buildAtUri, BOARD_COLLECTION } from "../lib/tid.js";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
|
|
7
|
+
export async function boardsCommand(opts: { json?: boolean }): Promise<void> {
|
|
8
|
+
const { agent, did } = await requireAgent();
|
|
9
|
+
|
|
10
|
+
const boards = await fetchMyBoards(agent, did);
|
|
11
|
+
|
|
12
|
+
// Also include known boards from other users
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
const defaultBoard = getDefaultBoard();
|
|
15
|
+
|
|
16
|
+
if (opts.json) {
|
|
17
|
+
console.log(JSON.stringify(boards.map((b) => ({
|
|
18
|
+
rkey: b.rkey,
|
|
19
|
+
did: b.did,
|
|
20
|
+
name: b.name,
|
|
21
|
+
description: b.description,
|
|
22
|
+
columns: b.columns.length,
|
|
23
|
+
open: b.open ?? false,
|
|
24
|
+
})), null, 2));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (boards.length === 0) {
|
|
29
|
+
console.log("No boards found.");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log(chalk.bold("Your boards:\n"));
|
|
34
|
+
|
|
35
|
+
for (const board of boards) {
|
|
36
|
+
const isDefault = defaultBoard?.did === board.did && defaultBoard?.rkey === board.rkey;
|
|
37
|
+
const marker = isDefault ? chalk.green(" *") : " ";
|
|
38
|
+
const cols = board.columns.sort((a, b) => a.order - b.order);
|
|
39
|
+
const colSummary = cols.map((c) => c.name).join(" | ");
|
|
40
|
+
|
|
41
|
+
console.log(`${marker} ${chalk.bold(board.name)} ${chalk.dim(`(${board.rkey})`)}`);
|
|
42
|
+
if (board.description) {
|
|
43
|
+
console.log(` ${chalk.dim(board.description)}`);
|
|
44
|
+
}
|
|
45
|
+
console.log(` ${chalk.dim(colSummary)}`);
|
|
46
|
+
console.log();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Show known boards from other users
|
|
50
|
+
const otherBoards = config.knownBoards.filter((b) => b.did !== did);
|
|
51
|
+
if (otherBoards.length > 0) {
|
|
52
|
+
console.log(chalk.bold("Joined boards:\n"));
|
|
53
|
+
for (const board of otherBoards) {
|
|
54
|
+
const isDefault = defaultBoard?.did === board.did && defaultBoard?.rkey === board.rkey;
|
|
55
|
+
const marker = isDefault ? chalk.green(" *") : " ";
|
|
56
|
+
console.log(`${marker} ${chalk.bold(board.name)} ${chalk.dim(`(${board.did.slice(0, 20)}.../${board.rkey})`)}`);
|
|
57
|
+
}
|
|
58
|
+
console.log();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (defaultBoard) {
|
|
62
|
+
console.log(chalk.dim(`* = default board. Change with: sb use <board>`));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { requireAgent } from "../lib/auth.js";
|
|
2
|
+
import { fetchBoardData } from "../lib/pds.js";
|
|
3
|
+
import { getDefaultBoard } from "../lib/config.js";
|
|
4
|
+
import { printBoardCards, shortRkey, formatDate } from "../lib/display.js";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
|
|
7
|
+
export async function cardsCommand(opts: {
|
|
8
|
+
column?: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
search?: string;
|
|
11
|
+
board?: string;
|
|
12
|
+
json?: boolean;
|
|
13
|
+
}): Promise<void> {
|
|
14
|
+
const { did } = await requireAgent();
|
|
15
|
+
|
|
16
|
+
const boardRef = resolveBoard(opts.board);
|
|
17
|
+
|
|
18
|
+
const data = await fetchBoardData(boardRef.did, boardRef.rkey, did);
|
|
19
|
+
if (!data) {
|
|
20
|
+
console.error(chalk.red("Board not found."));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (opts.json) {
|
|
25
|
+
const sorted = [...data.board.columns].sort((a, b) => a.order - b.order);
|
|
26
|
+
const result = sorted.map((col) => ({
|
|
27
|
+
column: col.name,
|
|
28
|
+
cards: data.tasks
|
|
29
|
+
.filter((t) => t.effectiveColumnId === col.id)
|
|
30
|
+
.sort((a, b) => a.effectivePosition.localeCompare(b.effectivePosition))
|
|
31
|
+
.map((t) => ({
|
|
32
|
+
rkey: t.rkey,
|
|
33
|
+
shortRef: shortRkey(t.rkey),
|
|
34
|
+
title: t.effectiveTitle,
|
|
35
|
+
description: t.effectiveDescription,
|
|
36
|
+
labels: t.effectiveLabelIds,
|
|
37
|
+
createdAt: t.createdAt,
|
|
38
|
+
lastModifiedAt: t.lastModifiedAt,
|
|
39
|
+
})),
|
|
40
|
+
}));
|
|
41
|
+
console.log(JSON.stringify(result, null, 2));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log(chalk.bold(data.board.name));
|
|
46
|
+
printBoardCards(data.board, data.tasks, {
|
|
47
|
+
column: opts.column,
|
|
48
|
+
label: opts.label,
|
|
49
|
+
search: opts.search,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveBoard(boardOpt?: string): { did: string; rkey: string } {
|
|
54
|
+
if (boardOpt) {
|
|
55
|
+
const defaultBoard = getDefaultBoard();
|
|
56
|
+
if (defaultBoard) return defaultBoard;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const defaultBoard = getDefaultBoard();
|
|
60
|
+
if (!defaultBoard) {
|
|
61
|
+
console.error(chalk.red("No default board set. Run `sb use <board>` first."));
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
return defaultBoard;
|
|
65
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { requireAgent } from "../lib/auth.js";
|
|
2
|
+
import { fetchBoardData } from "../lib/pds.js";
|
|
3
|
+
import { getDefaultBoard } from "../lib/config.js";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
|
|
6
|
+
export async function colsCommand(opts: { board?: string; json?: boolean }): Promise<void> {
|
|
7
|
+
const { did } = await requireAgent();
|
|
8
|
+
|
|
9
|
+
const boardRef = resolveBoard(opts.board);
|
|
10
|
+
|
|
11
|
+
const data = await fetchBoardData(boardRef.did, boardRef.rkey, did);
|
|
12
|
+
if (!data) {
|
|
13
|
+
console.error(chalk.red("Board not found."));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const sortedColumns = [...data.board.columns].sort((a, b) => a.order - b.order);
|
|
18
|
+
|
|
19
|
+
if (opts.json) {
|
|
20
|
+
console.log(JSON.stringify(sortedColumns.map((col, i) => ({
|
|
21
|
+
index: i + 1,
|
|
22
|
+
id: col.id,
|
|
23
|
+
name: col.name,
|
|
24
|
+
taskCount: data.tasks.filter((t) => t.effectiveColumnId === col.id).length,
|
|
25
|
+
})), null, 2));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(chalk.bold(data.board.name) + "\n");
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < sortedColumns.length; i++) {
|
|
32
|
+
const col = sortedColumns[i];
|
|
33
|
+
const count = data.tasks.filter((t) => t.effectiveColumnId === col.id).length;
|
|
34
|
+
console.log(` ${chalk.dim(`${i + 1}.`)} ${col.name} ${chalk.dim(`(${count})`)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveBoard(boardOpt?: string): { did: string; rkey: string } {
|
|
39
|
+
if (boardOpt) {
|
|
40
|
+
// TODO: support full board ref parsing like use command
|
|
41
|
+
const defaultBoard = getDefaultBoard();
|
|
42
|
+
if (defaultBoard) return defaultBoard;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const defaultBoard = getDefaultBoard();
|
|
46
|
+
if (!defaultBoard) {
|
|
47
|
+
console.error(chalk.red("No default board set. Run `sb use <board>` first."));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
return defaultBoard;
|
|
51
|
+
}
|