@tenyokjs/devdevian-cli 0.0.0-stage → 0.2.0-alpha.1

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/lib/client.mjs ADDED
@@ -0,0 +1,143 @@
1
+ import {
2
+ readFile,
3
+ writeFile,
4
+ mkdir,
5
+ chmod,
6
+ rename,
7
+ unlink,
8
+ lstat,
9
+ } from "node:fs/promises";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { randomBytes } from "node:crypto";
13
+ export function originOf(value) {
14
+ const url = new URL(value);
15
+ if (
16
+ url.username ||
17
+ url.password ||
18
+ url.search ||
19
+ url.hash ||
20
+ url.pathname !== "/" ||
21
+ !(
22
+ url.protocol === "https:" ||
23
+ (url.protocol === "http:" &&
24
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))
25
+ )
26
+ )
27
+ throw Error(
28
+ "Use an HTTPS server origin, or HTTP on loopback for local development.",
29
+ );
30
+ return url.origin;
31
+ }
32
+ export const clean = (value) =>
33
+ String(value ?? "")
34
+ .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "")
35
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
36
+ .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, "");
37
+ export function configFile(env = process.env) {
38
+ return join(
39
+ env.DEVDEVIAN_CONFIG_DIR || join(homedir(), ".config", "devdevian"),
40
+ "credentials.json",
41
+ );
42
+ }
43
+ export async function loadConfig() {
44
+ try {
45
+ const path = configFile(),
46
+ info = await lstat(path);
47
+ if (!info.isFile() || info.isSymbolicLink())
48
+ throw Error("The credentials path must be a regular file.");
49
+ if (process.platform !== "win32" && info.mode & 0o077)
50
+ throw Error(
51
+ "Credentials permissions are too broad. Run chmod 600 on " + path,
52
+ );
53
+ const config = JSON.parse(await readFile(path, "utf8"));
54
+ config.origin = originOf(config.origin);
55
+ if (!/^ddv_[A-Za-z0-9_-]{43}$/.test(config.token || ""))
56
+ throw Error("Invalid saved CLI credentials. Sign in again.");
57
+ return config;
58
+ } catch (e) {
59
+ if (e.code === "ENOENT") return null;
60
+ throw e;
61
+ }
62
+ }
63
+ export async function saveConfig(config) {
64
+ const file = configFile(),
65
+ dir = join(file, "..");
66
+ await mkdir(dir, { recursive: true, mode: 0o700 });
67
+ const info = await lstat(dir);
68
+ if (info.isSymbolicLink() || !info.isDirectory())
69
+ throw Error("The credentials directory must be a real directory.");
70
+ const temp = join(dir, ".credentials-" + randomBytes(8).toString("hex"));
71
+ try {
72
+ await writeFile(temp, JSON.stringify(config) + "\n", {
73
+ mode: 0o600,
74
+ flag: "wx",
75
+ });
76
+ await chmod(temp, 0o600);
77
+ await rename(temp, file);
78
+ } finally {
79
+ await unlink(temp).catch((e) => {
80
+ if (e.code !== "ENOENT") throw e;
81
+ });
82
+ }
83
+ }
84
+ export async function removeConfig() {
85
+ try {
86
+ await unlink(configFile());
87
+ } catch (e) {
88
+ if (e.code !== "ENOENT") throw e;
89
+ }
90
+ }
91
+ export function client(origin, token = null) {
92
+ origin = originOf(origin);
93
+ return async (path, { method = "GET", body, signal } = {}) => {
94
+ if (!path.startsWith("/") || path.startsWith("//"))
95
+ throw Error("Invalid API path.");
96
+ let response;
97
+ try {
98
+ response = await fetch(origin + "/api" + path, {
99
+ method,
100
+ redirect: "error",
101
+ headers: {
102
+ Accept: "application/json",
103
+ ...(body ? { "Content-Type": "application/json" } : {}),
104
+ ...(token ? { Authorization: "Bearer " + token } : {}),
105
+ },
106
+ body: body ? JSON.stringify(body) : undefined,
107
+ signal: signal
108
+ ? AbortSignal.any([signal, AbortSignal.timeout(20000)])
109
+ : AbortSignal.timeout(20000),
110
+ });
111
+ } catch (error) {
112
+ if (signal?.aborted) throw error;
113
+ throw Error(
114
+ "Could not reach Devdevian. Check your connection and server address.",
115
+ );
116
+ }
117
+ const chunks = [];
118
+ let size = 0;
119
+ for await (const chunk of response.body) {
120
+ size += chunk.length;
121
+ if (size > 2 * 1024 * 1024)
122
+ throw Error("Devdevian response is too large. Use a smaller page.");
123
+ chunks.push(chunk);
124
+ }
125
+ const text = Buffer.concat(chunks).toString("utf8");
126
+ let data;
127
+ try {
128
+ data = JSON.parse(text);
129
+ } catch {
130
+ throw Error(
131
+ `Devdevian returned an unexpected response (HTTP ${response.status}). Try again later.`,
132
+ );
133
+ }
134
+ if (!response.ok) {
135
+ const error = Error(
136
+ clean(data.error || `Request failed (HTTP ${response.status}).`),
137
+ );
138
+ error.status = response.status;
139
+ throw error;
140
+ }
141
+ return data;
142
+ };
143
+ }
@@ -0,0 +1,199 @@
1
+ export const navigation = [
2
+ ["inbox", "Inbox", "Replies & mentions"],
3
+ ["feed", "Public feed", "Developer commons"],
4
+ ["open", "Open conversation", "Jump to a post"],
5
+ ["ask", "Ask a question", "Find a way forward"],
6
+ ["review", "Request code review", "Another pair of eyes"],
7
+ ["communities", "Communities", "Your private spaces"],
8
+ ["search", "Search", "Find a conversation"],
9
+ ["devices", "Connected terminals", "Manage CLI access"],
10
+ ["help", "Help", "Keys & commands"],
11
+ ];
12
+ export async function loadPage(api, route, signal) {
13
+ const get = (path) => api(path, { signal }),
14
+ page = route.page || 1;
15
+ if (route.view === "feed")
16
+ return {
17
+ ...(await get("/posts?limit=12&page=" + page)),
18
+ title: "Public feed",
19
+ subtitle: "Questions, ideas and code worth discussing.",
20
+ };
21
+ if (route.view === "inbox")
22
+ return {
23
+ ...(await get("/notifications?page=" + page)),
24
+ title: "Inbox",
25
+ subtitle: "Your replies, mentions and review updates.",
26
+ };
27
+ if (route.view === "communities")
28
+ return {
29
+ ...(await get("/communities?limit=12&page=" + page)),
30
+ title: "Communities",
31
+ subtitle: "Private spaces, shared context. Enter opens a community.",
32
+ };
33
+ if (route.view === "search")
34
+ return {
35
+ ...(await get(
36
+ "/search?type=" +
37
+ (route.type || "posts") +
38
+ "&q=" +
39
+ encodeURIComponent(route.query || "") +
40
+ "&page=" +
41
+ page,
42
+ )),
43
+ title: "Search",
44
+ subtitle: route.query
45
+ ? "Results for “" + route.query + "”"
46
+ : "Press / and type /search followed by your query.",
47
+ };
48
+ if (route.view === "devices")
49
+ return {
50
+ ...(await get("/cli/sessions")),
51
+ title: "Connected terminals",
52
+ subtitle: "Enter to revoke a terminal. Revoking this one signs it out.",
53
+ };
54
+ if (route.view === "community") {
55
+ const community = await get("/communities/" + encodeURIComponent(route.id));
56
+ const posts = community.is_member
57
+ ? await get(
58
+ "/posts?limit=12&community=" +
59
+ encodeURIComponent(route.id) +
60
+ "&page=" +
61
+ page,
62
+ )
63
+ : { items: [] };
64
+ return {
65
+ ...posts,
66
+ community,
67
+ title: community.name,
68
+ subtitle: community.is_member
69
+ ? "Members-only conversations. N creates a conversation here."
70
+ : community.requested
71
+ ? "Your access request is pending."
72
+ : "Private community. J requests access.",
73
+ };
74
+ }
75
+ if (route.view === "post") {
76
+ const post = await get("/posts/" + route.id + "?reply_page=" + page);
77
+ let items = post.replies,
78
+ pagination = post.reply_pagination;
79
+ if (route.tab === "feedback") {
80
+ const feedback = await get(
81
+ "/posts/" + route.id + "/code-reviews?page=" + page,
82
+ );
83
+ items = feedback.items;
84
+ pagination = feedback.pagination;
85
+ }
86
+ return {
87
+ post,
88
+ items,
89
+ pagination,
90
+ title: post.title,
91
+ subtitle:
92
+ (post.community_id ? "Private community" : "Public feed") +
93
+ " · " +
94
+ post.kind +
95
+ " · by " +
96
+ post.name,
97
+ };
98
+ }
99
+ if (route.view === "thread") {
100
+ const post = await get("/posts/" + route.postId),
101
+ replies = await get(
102
+ `/posts/${route.postId}/replies?parent_id=${route.parent.id}&page=${page}`,
103
+ );
104
+ return {
105
+ post,
106
+ ...replies,
107
+ title: "Reply thread",
108
+ subtitle: "Replying to " + route.parent.name,
109
+ };
110
+ }
111
+ if (route.view === "feedback") {
112
+ const [post, notes] = await Promise.all([
113
+ get("/posts/" + route.postId),
114
+ get(`/posts/${route.postId}/code-reviews?target_id=${route.id}`),
115
+ ]);
116
+ const note = notes.items.find((item) => item.id === Number(route.id));
117
+ if (!note) throw Error("This feedback is no longer available.");
118
+ const replies = await get(
119
+ `/posts/${route.postId}/code-reviews/${route.id}/replies?page=${page}${route.targetReply ? "&target_id=" + route.targetReply : ""}`,
120
+ );
121
+ return {
122
+ post,
123
+ note,
124
+ items: [
125
+ { ...note, entryType: "note" },
126
+ ...replies.items.map((item) => ({
127
+ ...item,
128
+ entryType: "feedbackReply",
129
+ })),
130
+ ],
131
+ pagination: replies.pagination,
132
+ title: "Code feedback",
133
+ subtitle: `Lines ${note.start_line}–${note.end_line} · ${note.resolved ? "resolved" : "open"}`,
134
+ };
135
+ }
136
+ return { items: [], title: "Your workspace", subtitle: "" };
137
+ }
138
+ export function contentLines(route, data) {
139
+ if (route.view === "help")
140
+ return [
141
+ "A developer workspace, in your terminal.",
142
+ "",
143
+ "Tab Switch navigation / content / command bar",
144
+ "↑ ↓ Select a section, conversation or reply",
145
+ "Enter Open the selected item",
146
+ "Esc Back / leave an input",
147
+ "← → Switch conversation tabs",
148
+ "Page Up / Down Scroll a conversation or list",
149
+ "N / A New conversation / ask a question",
150
+ "R Reply in a conversation; refresh a list",
151
+ "C Add feedback on code lines",
152
+ "S Resolve / reopen selected code feedback",
153
+ "E / D Edit / delete your own feedback or reply",
154
+ "O Open this conversation in your browser",
155
+ "/ Focus the command / search bar",
156
+ "Ctrl+S Preview the current form",
157
+ "Ctrl+C Exit and restore the original terminal",
158
+ "",
159
+ "Commands: /feed /inbox /search words /open 142",
160
+ " /ask /review /communities /devices /logout /quit",
161
+ "",
162
+ "Text fields: arrows move the caret; Home/End move within a line.",
163
+ "Multiline fields: Enter adds a line; Tab changes field.",
164
+ "Nothing is published until you confirm the preview.",
165
+ "A -> Z: your browser account and this terminal use the same data.",
166
+ ];
167
+ if (route.view === "community" && !data.community?.is_member)
168
+ return [
169
+ data.community?.description || "",
170
+ "",
171
+ data.community?.requested
172
+ ? "Access requested. The owner or validators will review it."
173
+ : "Press J to request access.",
174
+ ];
175
+ if (route.view === "post" && (!route.tab || route.tab === "overview")) {
176
+ const p = data.post;
177
+ if (!p) return [];
178
+ const lines = [p.body || ""];
179
+ if (p.review_focus) lines.push("", "REVIEW FOCUS", p.review_focus);
180
+ if (p.code)
181
+ lines.push(
182
+ "",
183
+ "CODE SNIPPET",
184
+ ...p.code
185
+ .split("\n")
186
+ .map((line, index) => String(index + 1).padStart(4) + " " + line),
187
+ );
188
+ if (p.links?.length)
189
+ lines.push(
190
+ "",
191
+ "LINKS",
192
+ ...p.links.map((link) => link.label + " · " + link.url),
193
+ );
194
+ if (p.archived_at || p.locked_at)
195
+ lines.push("", "Replies are paused for this conversation.");
196
+ return lines;
197
+ }
198
+ return [];
199
+ }
@@ -0,0 +1,167 @@
1
+ import stringWidth from "string-width";
2
+ import wrapAnsi from "wrap-ansi";
3
+ import { clean } from "./client.mjs";
4
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
5
+ export const graphemes = (value) =>
6
+ Array.from(segmenter.segment(value), (part) => part.segment);
7
+ export function clip(value, width) {
8
+ const text = clean(value).replace(/\n/g, " ");
9
+ if (width <= 0) return "";
10
+ if (stringWidth(text) <= width) return text;
11
+ let result = "";
12
+ for (const part of graphemes(text)) {
13
+ if (stringWidth(result + part) > width - 1) break;
14
+ result += part;
15
+ }
16
+ return result + "…";
17
+ }
18
+ export function wrap(value, width) {
19
+ return wrapAnsi(clean(value).replace(/\t/g, " "), Math.max(1, width), {
20
+ hard: true,
21
+ trim: false,
22
+ }).split("\n");
23
+ }
24
+ export function editText(
25
+ value,
26
+ cursor,
27
+ input,
28
+ key,
29
+ { multiline = false, max = 15000 } = {},
30
+ ) {
31
+ const chars = graphemes(value),
32
+ at = Math.max(0, Math.min(cursor, chars.length));
33
+ if (multiline && (key.upArrow || key.downArrow)) {
34
+ const start = chars.slice(0, at).lastIndexOf("\n") + 1,
35
+ column = at - start;
36
+ if (key.upArrow) {
37
+ if (!start) return { value, cursor: 0 };
38
+ const previous = chars.slice(0, start - 1).lastIndexOf("\n") + 1;
39
+ return { value, cursor: Math.min(start - 1, previous + column) };
40
+ }
41
+ const end = chars.indexOf("\n", at);
42
+ if (end < 0) return { value, cursor: chars.length };
43
+ const next = chars.indexOf("\n", end + 1);
44
+ return {
45
+ value,
46
+ cursor: Math.min(next < 0 ? chars.length : next, end + 1 + column),
47
+ };
48
+ }
49
+ if (key.leftArrow) return { value, cursor: Math.max(0, at - 1) };
50
+ if (key.rightArrow) return { value, cursor: Math.min(chars.length, at + 1) };
51
+ if (key.home || (key.ctrl && input === "a")) {
52
+ const previous = chars.slice(0, at).lastIndexOf("\n");
53
+ return { value, cursor: previous + 1 };
54
+ }
55
+ if (key.end || (key.ctrl && input === "e")) {
56
+ const next = chars.indexOf("\n", at);
57
+ return { value, cursor: next < 0 ? chars.length : next };
58
+ }
59
+ if (key.ctrl && input === "u") {
60
+ let start = chars.slice(0, at).lastIndexOf("\n") + 1;
61
+ return {
62
+ value: [...chars.slice(0, start), ...chars.slice(at)].join(""),
63
+ cursor: start,
64
+ };
65
+ }
66
+ if (key.ctrl && input === "k") {
67
+ const end = chars.indexOf("\n", at);
68
+ return {
69
+ value: [...chars.slice(0, at), ...(end < 0 ? [] : chars.slice(end))].join(
70
+ "",
71
+ ),
72
+ cursor: at,
73
+ };
74
+ }
75
+ if (key.backspace) {
76
+ if (!at) return { value, cursor: at };
77
+ return {
78
+ value: [...chars.slice(0, at - 1), ...chars.slice(at)].join(""),
79
+ cursor: at - 1,
80
+ };
81
+ }
82
+ if (key.delete)
83
+ return {
84
+ value: [...chars.slice(0, at), ...chars.slice(at + 1)].join(""),
85
+ cursor: at,
86
+ };
87
+ if (key.ctrl || key.meta || key.escape || key.tab)
88
+ return { value, cursor: at };
89
+ let insertion = key.return
90
+ ? multiline
91
+ ? "\n"
92
+ : ""
93
+ : clean(input).replace(/\r/g, "");
94
+ if (!multiline) insertion = insertion.replace(/\n/g, " ");
95
+ if (!insertion) return { value, cursor: at };
96
+ const remaining = Math.max(0, max - value.length),
97
+ parts = [];
98
+ let length = 0;
99
+ for (const part of graphemes(insertion)) {
100
+ if (length + part.length > remaining) break;
101
+ parts.push(part);
102
+ length += part.length;
103
+ }
104
+ return {
105
+ value: [...chars.slice(0, at), ...parts, ...chars.slice(at)].join(""),
106
+ cursor: at + parts.length,
107
+ };
108
+ }
109
+ export function cursorLines(value, cursor, width) {
110
+ const parts = graphemes(value),
111
+ lines = wrap(
112
+ parts.slice(0, cursor).join("") + "▏" + parts.slice(cursor).join(""),
113
+ width,
114
+ );
115
+ const marker = lines.findIndex((line) => line.includes("▏"));
116
+ return { lines, line: Math.max(0, marker) };
117
+ }
118
+ export function parseCommand(text) {
119
+ const [cmd, ...rest] = text.trim().replace(/^\//, "").split(/\s+/);
120
+ const arg = rest.join(" ");
121
+ if (
122
+ [
123
+ "feed",
124
+ "inbox",
125
+ "communities",
126
+ "search",
127
+ "ask",
128
+ "review",
129
+ "help",
130
+ "devices",
131
+ ].includes(cmd)
132
+ )
133
+ return { command: cmd, arg };
134
+ if (
135
+ cmd === "open" &&
136
+ /^[1-9]\d*$/.test(arg) &&
137
+ Number.isSafeInteger(Number(arg))
138
+ )
139
+ return { command: cmd, arg: Number(arg) };
140
+ if (["quit", "logout"].includes(cmd)) return { command: cmd };
141
+ throw Error(
142
+ "Use /feed, /inbox, /search words, /open ID, /ask, /review, /communities, /devices, /help or /quit.",
143
+ );
144
+ }
145
+ export class PageCache {
146
+ constructor({ max = 40, ttl = 30000 } = {}) {
147
+ this.rows = new Map();
148
+ this.max = max;
149
+ this.ttl = ttl;
150
+ }
151
+ get(key) {
152
+ const row = this.rows.get(key);
153
+ if (!row || Date.now() - row.time > this.ttl) return null;
154
+ this.rows.delete(key);
155
+ this.rows.set(key, row);
156
+ return row.value;
157
+ }
158
+ set(key, value) {
159
+ this.rows.delete(key);
160
+ this.rows.set(key, { value, time: Date.now() });
161
+ while (this.rows.size > this.max)
162
+ this.rows.delete(this.rows.keys().next().value);
163
+ }
164
+ clear() {
165
+ this.rows.clear();
166
+ }
167
+ }