@every-env/spiral-cli 0.2.0 → 1.0.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 +35 -6
- package/package.json +5 -14
- package/src/api.ts +82 -474
- package/src/auth.ts +49 -214
- package/src/cli.ts +264 -948
- package/src/config.ts +29 -45
- package/src/output.ts +162 -0
- package/src/types.ts +87 -117
- package/src/attachments/index.ts +0 -174
- package/src/drafts/editor.ts +0 -105
- package/src/drafts/index.ts +0 -208
- package/src/notes/index.ts +0 -130
- package/src/styles/index.ts +0 -45
- package/src/suggestions/diff.ts +0 -33
- package/src/suggestions/index.ts +0 -205
- package/src/suggestions/parser.ts +0 -83
- package/src/tools/renderer.ts +0 -104
- package/src/workspaces/index.ts +0 -55
package/src/config.ts
CHANGED
|
@@ -1,57 +1,41 @@
|
|
|
1
|
-
// Local configuration store using `conf` package
|
|
2
1
|
import Conf from "conf";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
sessionId: string;
|
|
8
|
-
title?: string;
|
|
9
|
-
localContent?: string; // Unsaved local changes
|
|
10
|
-
lastSyncedAt: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// Note item for scratchpad
|
|
14
|
-
export interface NoteItem {
|
|
15
|
-
id: string;
|
|
16
|
-
content: string;
|
|
17
|
-
feedback?: string;
|
|
18
|
-
createdAt: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// User preferences
|
|
22
|
-
export interface SpiralPreferences {
|
|
23
|
-
editor?: string; // Override $EDITOR
|
|
24
|
-
diffTool?: "inline" | "side-by-side";
|
|
25
|
-
toolCallVerbosity?: "minimal" | "normal" | "verbose";
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Auth credentials
|
|
29
|
-
export interface AuthCredentials {
|
|
30
|
-
token: string; // Personal Access Token (PAT)
|
|
31
|
-
tokenPrefix: string; // For display (spiral_sk_abc...)
|
|
3
|
+
interface AuthCredentials {
|
|
4
|
+
token: string;
|
|
5
|
+
tokenPrefix: string;
|
|
32
6
|
createdAt: string;
|
|
33
7
|
}
|
|
34
8
|
|
|
35
|
-
|
|
36
|
-
export interface SpiralConfig {
|
|
37
|
-
currentWorkspaceId?: string;
|
|
38
|
-
currentStyleId?: string;
|
|
39
|
-
defaultModel?: string;
|
|
40
|
-
drafts: Record<string, LocalDraft>;
|
|
41
|
-
notes: NoteItem[];
|
|
42
|
-
preferences: SpiralPreferences;
|
|
9
|
+
interface SpiralConfig {
|
|
43
10
|
auth?: AuthCredentials;
|
|
44
11
|
}
|
|
45
12
|
|
|
46
|
-
|
|
47
|
-
export const config = new Conf<SpiralConfig>({
|
|
13
|
+
const config = new Conf<SpiralConfig>({
|
|
48
14
|
projectName: "spiral-cli",
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
15
|
+
schema: {
|
|
16
|
+
auth: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
token: { type: "string" },
|
|
20
|
+
tokenPrefix: { type: "string" },
|
|
21
|
+
createdAt: { type: "string" },
|
|
22
|
+
},
|
|
55
23
|
},
|
|
56
24
|
},
|
|
57
25
|
});
|
|
26
|
+
|
|
27
|
+
export function getStoredAuth(): AuthCredentials | undefined {
|
|
28
|
+
return config.get("auth");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function storeAuth(token: string): void {
|
|
32
|
+
config.set("auth", {
|
|
33
|
+
token,
|
|
34
|
+
tokenPrefix: token.slice(0, 14) + "...",
|
|
35
|
+
createdAt: new Date().toISOString(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function clearAuth(): void {
|
|
40
|
+
config.delete("auth");
|
|
41
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { Marked } from "marked";
|
|
2
|
+
import { markedTerminal } from "marked-terminal";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import ora, { type Ora } from "ora";
|
|
5
|
+
import { theme } from "./theme.js";
|
|
6
|
+
import type {
|
|
7
|
+
GenerateResponse,
|
|
8
|
+
DraftOutput,
|
|
9
|
+
WritingStyle,
|
|
10
|
+
Workspace,
|
|
11
|
+
Conversation,
|
|
12
|
+
Draft,
|
|
13
|
+
SessionQuotaResponse,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
const marked = new Marked(markedTerminal({ reflowText: true, width: 80 }));
|
|
17
|
+
|
|
18
|
+
/** Output raw JSON to stdout. */
|
|
19
|
+
export function outputJson(data: unknown): void {
|
|
20
|
+
console.log(JSON.stringify(data, null, 2));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Render markdown to terminal. */
|
|
24
|
+
export function outputMarkdown(md: string): void {
|
|
25
|
+
const rendered = marked.parse(md) as string;
|
|
26
|
+
process.stdout.write(rendered);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Run an async operation with a spinner. */
|
|
30
|
+
export async function withSpinner<T>(
|
|
31
|
+
label: string,
|
|
32
|
+
fn: (spinner: Ora) => Promise<T>,
|
|
33
|
+
): Promise<T> {
|
|
34
|
+
const spinner = ora(label).start();
|
|
35
|
+
try {
|
|
36
|
+
const result = await fn(spinner);
|
|
37
|
+
spinner.stop();
|
|
38
|
+
return result;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
spinner.stop();
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Human-readable formatters ──
|
|
46
|
+
|
|
47
|
+
export function formatDrafts(drafts: DraftOutput[]): void {
|
|
48
|
+
if (drafts.length === 0) {
|
|
49
|
+
console.log(theme.dim("No drafts."));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
for (let i = 0; i < drafts.length; i++) {
|
|
53
|
+
const d = drafts[i]!;
|
|
54
|
+
console.log(theme.heading(`\n── Draft ${i + 1} ──`));
|
|
55
|
+
if (d.title) console.log(theme.info(`Title: ${d.title}`));
|
|
56
|
+
if (d.url) console.log(theme.dim(`URL: ${d.url}`));
|
|
57
|
+
if (d.content) outputMarkdown(d.content);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatGenerateResponse(resp: GenerateResponse): void {
|
|
62
|
+
if (resp.notice) console.log(theme.warning(resp.notice));
|
|
63
|
+
|
|
64
|
+
if (resp.status === "needs_input" && resp.messages) {
|
|
65
|
+
console.log(theme.heading("\nSpiral needs more info:\n"));
|
|
66
|
+
for (const msg of resp.messages) {
|
|
67
|
+
outputMarkdown(msg);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (resp.text) outputMarkdown(resp.text);
|
|
73
|
+
if (resp.drafts) formatDrafts(resp.drafts);
|
|
74
|
+
|
|
75
|
+
if (resp.style_used) {
|
|
76
|
+
console.log(theme.dim(`\nStyle: ${resp.style_used.name}`));
|
|
77
|
+
}
|
|
78
|
+
if (resp.quota_remaining !== undefined && resp.quota_remaining !== null) {
|
|
79
|
+
console.log(theme.dim(`Sessions remaining: ${resp.quota_remaining}`));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatStyles(styles: WritingStyle[]): void {
|
|
84
|
+
if (styles.length === 0) {
|
|
85
|
+
console.log(theme.dim("No writing styles configured."));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
for (const s of styles) {
|
|
89
|
+
const desc = s.description ? theme.dim(s.description.slice(0, 60)) : "";
|
|
90
|
+
console.log(
|
|
91
|
+
` ${theme.info(s.id.slice(0, 8))} ${chalk.bold(s.name)} ${desc}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function formatWorkspaces(workspaces: Workspace[]): void {
|
|
97
|
+
if (workspaces.length === 0) {
|
|
98
|
+
console.log(theme.dim("No workspaces."));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const personal = workspaces.filter((w) => w.is_personal);
|
|
102
|
+
const team = workspaces.filter((w) => !w.is_personal);
|
|
103
|
+
|
|
104
|
+
if (personal.length) {
|
|
105
|
+
console.log(theme.heading("\nPersonal"));
|
|
106
|
+
for (const w of personal) {
|
|
107
|
+
console.log(` ${theme.info(w.id.slice(0, 8))} ${w.name}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (team.length) {
|
|
111
|
+
console.log(theme.heading("\nTeam"));
|
|
112
|
+
for (const w of team) {
|
|
113
|
+
console.log(` ${theme.info(w.id.slice(0, 8))} ${w.name}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function formatConversations(conversations: Conversation[]): void {
|
|
119
|
+
if (conversations.length === 0) {
|
|
120
|
+
console.log(theme.dim("No sessions."));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
for (const c of conversations) {
|
|
124
|
+
const date = new Date(c.updated_at).toLocaleDateString();
|
|
125
|
+
console.log(
|
|
126
|
+
` ${theme.info(c.session_id.slice(0, 8))} ${c.session_name || theme.dim("(untitled)")} ${theme.dim(date)}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function formatSessionDrafts(drafts: Draft[]): void {
|
|
132
|
+
if (drafts.length === 0) {
|
|
133
|
+
console.log(theme.dim("No drafts in this session."));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (const d of drafts) {
|
|
137
|
+
const date = new Date(d.updated_at).toLocaleDateString();
|
|
138
|
+
console.log(
|
|
139
|
+
` ${theme.info(d.id.slice(0, 8))} ${d.title || theme.dim("(untitled)")} ${theme.dim(date)}`,
|
|
140
|
+
);
|
|
141
|
+
if (d.content) {
|
|
142
|
+
const preview = d.content.replace(/\n/g, " ").slice(0, 80);
|
|
143
|
+
console.log(` ${theme.dim(preview)}${d.content.length > 80 ? "..." : ""}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function formatQuota(q: SessionQuotaResponse): void {
|
|
149
|
+
console.log(theme.heading("Session Quota"));
|
|
150
|
+
console.log(` Plan: ${chalk.bold(q.plan_tier)}`);
|
|
151
|
+
console.log(` Used: ${q.used} / ${q.total}`);
|
|
152
|
+
console.log(` Remaining: ${q.remaining}`);
|
|
153
|
+
if (q.resets_at) {
|
|
154
|
+
console.log(` Resets: ${new Date(q.resets_at).toLocaleDateString()}`);
|
|
155
|
+
}
|
|
156
|
+
if (q.topup_available > 0) {
|
|
157
|
+
console.log(` Top-ups: ${q.topup_available} available`);
|
|
158
|
+
}
|
|
159
|
+
if (q.team_name) {
|
|
160
|
+
console.log(` Team: ${q.team_name}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// Error
|
|
1
|
+
// ── Error classes ──
|
|
2
|
+
|
|
2
3
|
export class SpiralCliError extends Error {
|
|
3
4
|
constructor(
|
|
4
5
|
message: string,
|
|
5
|
-
public
|
|
6
|
+
public exitCode: number = 1,
|
|
6
7
|
) {
|
|
7
8
|
super(message);
|
|
8
9
|
this.name = "SpiralCliError";
|
|
@@ -10,8 +11,8 @@ export class SpiralCliError extends Error {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export class AuthenticationError extends SpiralCliError {
|
|
13
|
-
constructor(message: string) {
|
|
14
|
-
super(message, 2);
|
|
14
|
+
constructor(message: string = "Authentication required") {
|
|
15
|
+
super(message, 2);
|
|
15
16
|
this.name = "AuthenticationError";
|
|
16
17
|
}
|
|
17
18
|
}
|
|
@@ -19,152 +20,121 @@ export class AuthenticationError extends SpiralCliError {
|
|
|
19
20
|
export class ApiError extends SpiralCliError {
|
|
20
21
|
constructor(
|
|
21
22
|
message: string,
|
|
22
|
-
public
|
|
23
|
-
public readonly body?: unknown,
|
|
23
|
+
public statusCode?: number,
|
|
24
24
|
) {
|
|
25
|
-
super(message, 3);
|
|
25
|
+
super(message, 3);
|
|
26
26
|
this.name = "ApiError";
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export class NetworkError extends SpiralCliError {
|
|
31
|
-
constructor(message: string) {
|
|
32
|
-
super(message, 4);
|
|
31
|
+
constructor(message: string = "Network error") {
|
|
32
|
+
super(message, 4);
|
|
33
33
|
this.name = "NetworkError";
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
//
|
|
38
|
-
export interface ToolCall {
|
|
39
|
-
tool_name: string;
|
|
40
|
-
tool_call_id?: string;
|
|
41
|
-
call_id?: string;
|
|
42
|
-
tool_args?: Record<string, unknown>;
|
|
43
|
-
result?: unknown;
|
|
44
|
-
error?: string;
|
|
45
|
-
status: "started" | "arguments_delta" | "arguments_done" | "completed";
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface StreamEvent {
|
|
49
|
-
type: "content" | "thinking" | "tool_call" | "session_name" | "retry" | "complete" | "error";
|
|
50
|
-
content?: string;
|
|
51
|
-
thinking?: string;
|
|
52
|
-
toolCall?: ToolCall;
|
|
53
|
-
sessionId?: string;
|
|
54
|
-
sessionName?: string;
|
|
55
|
-
retryInfo?: { attempt: number; maxRetries: number; message: string };
|
|
56
|
-
model?: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Output formatters for agent-native support
|
|
60
|
-
export interface OutputFormatter {
|
|
61
|
-
content(text: string): void;
|
|
62
|
-
thinking(text: string): void;
|
|
63
|
-
error(error: Error): void;
|
|
64
|
-
complete(sessionId: string): void;
|
|
65
|
-
}
|
|
37
|
+
// ── API request/response types ──
|
|
66
38
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
39
|
+
export interface GenerateRequest {
|
|
40
|
+
prompt?: string;
|
|
41
|
+
style_id?: string;
|
|
42
|
+
workspace_id?: string;
|
|
43
|
+
session_id?: string;
|
|
44
|
+
mode?: "interactive" | "instant";
|
|
45
|
+
responses?: string;
|
|
46
|
+
num_drafts?: number;
|
|
75
47
|
}
|
|
76
48
|
|
|
77
|
-
export interface
|
|
49
|
+
export interface DraftOutput {
|
|
78
50
|
id: string;
|
|
79
|
-
|
|
80
|
-
content: string;
|
|
81
|
-
|
|
82
|
-
created_at: string;
|
|
83
|
-
updated_at?: string;
|
|
84
|
-
is_complete?: boolean;
|
|
85
|
-
model?: string;
|
|
86
|
-
provider?: string;
|
|
51
|
+
title: string | null;
|
|
52
|
+
content: string | null;
|
|
53
|
+
url: string | null;
|
|
87
54
|
}
|
|
88
55
|
|
|
89
|
-
|
|
90
|
-
export interface Draft {
|
|
56
|
+
export interface StyleInfo {
|
|
91
57
|
id: string;
|
|
92
|
-
|
|
93
|
-
user_id: string;
|
|
94
|
-
title?: string;
|
|
95
|
-
content: string;
|
|
96
|
-
content_hash: string;
|
|
97
|
-
current_version_id?: string;
|
|
98
|
-
created_at: string;
|
|
99
|
-
updated_at: string;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export interface DraftVersion {
|
|
103
|
-
id: string;
|
|
104
|
-
draft_id: string;
|
|
105
|
-
content: string;
|
|
106
|
-
content_hash: string;
|
|
107
|
-
created_by: "user" | "llm";
|
|
108
|
-
parent_version_id?: string;
|
|
109
|
-
created_at: string;
|
|
58
|
+
name: string;
|
|
110
59
|
}
|
|
111
60
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
61
|
+
export interface GenerateResponse {
|
|
62
|
+
session_id: string;
|
|
63
|
+
status: "needs_input" | "complete";
|
|
64
|
+
messages?: string[];
|
|
65
|
+
drafts?: DraftOutput[];
|
|
66
|
+
text?: string;
|
|
67
|
+
style_used?: StyleInfo;
|
|
68
|
+
notice?: string;
|
|
69
|
+
quota_remaining?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SessionQuotaResponse {
|
|
73
|
+
total: number;
|
|
74
|
+
used: number;
|
|
75
|
+
remaining: number;
|
|
76
|
+
plan_tier: string;
|
|
77
|
+
is_limited: boolean;
|
|
78
|
+
resets_at: string | null;
|
|
79
|
+
topup_available: number;
|
|
80
|
+
allowance_total: number | null;
|
|
81
|
+
subscription_type: string;
|
|
82
|
+
subscription_id: string | null;
|
|
83
|
+
team_id: string | null;
|
|
84
|
+
team_name: string | null;
|
|
85
|
+
personal_paused: boolean;
|
|
86
|
+
personal_pause_reason: string | null;
|
|
87
|
+
is_every_bundle_customer: boolean;
|
|
88
|
+
subscription_status: string | null;
|
|
89
|
+
current_period_end: string | null;
|
|
90
|
+
cancel_at_period_end: boolean;
|
|
91
|
+
subscription_quantity: number;
|
|
121
92
|
}
|
|
122
93
|
|
|
123
|
-
// Writing style types
|
|
124
94
|
export interface WritingStyle {
|
|
125
95
|
id: string;
|
|
126
96
|
name: string;
|
|
127
|
-
description
|
|
128
|
-
usage_context
|
|
129
|
-
|
|
130
|
-
|
|
97
|
+
description: string;
|
|
98
|
+
usage_context: string;
|
|
99
|
+
summary: string | null;
|
|
100
|
+
analysis_status: string;
|
|
101
|
+
guide: string | null;
|
|
102
|
+
voice_guide: string | null;
|
|
103
|
+
last_used_at: string | null;
|
|
104
|
+
approved_examples_count: number;
|
|
105
|
+
access_type: string;
|
|
106
|
+
workspace_id: string | null;
|
|
131
107
|
}
|
|
132
108
|
|
|
133
|
-
// Workspace types
|
|
134
109
|
export interface Workspace {
|
|
135
110
|
id: string;
|
|
136
111
|
name: string;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
team_id?: string;
|
|
112
|
+
description: string | null;
|
|
113
|
+
workspace_type: string;
|
|
114
|
+
is_personal: boolean;
|
|
115
|
+
icon: string | null;
|
|
116
|
+
icon_color: string | null;
|
|
143
117
|
}
|
|
144
118
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
mimeType?: string;
|
|
119
|
+
export interface Conversation {
|
|
120
|
+
session_id: string;
|
|
121
|
+
session_name: string;
|
|
122
|
+
workspace_id: string | null;
|
|
123
|
+
created_at: string;
|
|
124
|
+
updated_at: string;
|
|
152
125
|
}
|
|
153
126
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
127
|
+
export interface Draft {
|
|
128
|
+
id: string;
|
|
129
|
+
title: string;
|
|
130
|
+
content: string;
|
|
131
|
+
created_at: string;
|
|
132
|
+
updated_at: string;
|
|
159
133
|
}
|
|
160
134
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
args?: Record<string, unknown>;
|
|
167
|
-
result?: unknown;
|
|
168
|
-
error?: string;
|
|
169
|
-
formattedCalls?: string[];
|
|
135
|
+
export interface UserProfile {
|
|
136
|
+
id: string;
|
|
137
|
+
email: string;
|
|
138
|
+
name: string | null;
|
|
139
|
+
profile_image_url: string | null;
|
|
170
140
|
}
|
package/src/attachments/index.ts
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
// File attachment handling with security mitigations
|
|
2
|
-
// See plan: Path traversal and symlink security risks identified
|
|
3
|
-
|
|
4
|
-
import { lstatSync } from "node:fs";
|
|
5
|
-
import { resolve } from "node:path";
|
|
6
|
-
import ora from "ora";
|
|
7
|
-
import type { Attachment } from "../types";
|
|
8
|
-
|
|
9
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
10
|
-
const MAX_TOTAL_SIZE = 50 * 1024 * 1024; // 50MB total
|
|
11
|
-
const MAX_FILES = 5;
|
|
12
|
-
|
|
13
|
-
// SECURITY: Paths that should never be accessed
|
|
14
|
-
const SENSITIVE_PATHS = ["/etc", "/var", "/root", "/private"];
|
|
15
|
-
const HOME = process.env.HOME || "";
|
|
16
|
-
if (HOME) {
|
|
17
|
-
SENSITIVE_PATHS.push(`${HOME}/.ssh`, `${HOME}/.gnupg`, `${HOME}/.aws`);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Known text file extensions
|
|
21
|
-
const TEXT_EXTENSIONS = new Set([
|
|
22
|
-
".txt",
|
|
23
|
-
".md",
|
|
24
|
-
".json",
|
|
25
|
-
".yaml",
|
|
26
|
-
".yml",
|
|
27
|
-
".xml",
|
|
28
|
-
".html",
|
|
29
|
-
".css",
|
|
30
|
-
".js",
|
|
31
|
-
".ts",
|
|
32
|
-
".tsx",
|
|
33
|
-
".jsx",
|
|
34
|
-
".py",
|
|
35
|
-
".rb",
|
|
36
|
-
".go",
|
|
37
|
-
".rs",
|
|
38
|
-
".java",
|
|
39
|
-
".c",
|
|
40
|
-
".cpp",
|
|
41
|
-
".h",
|
|
42
|
-
".sh",
|
|
43
|
-
".bash",
|
|
44
|
-
".zsh",
|
|
45
|
-
".sql",
|
|
46
|
-
".csv",
|
|
47
|
-
".log",
|
|
48
|
-
".env",
|
|
49
|
-
".gitignore",
|
|
50
|
-
".toml",
|
|
51
|
-
".ini",
|
|
52
|
-
".conf",
|
|
53
|
-
".markdown",
|
|
54
|
-
".rst",
|
|
55
|
-
]);
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Validate and sanitize file path
|
|
59
|
-
* @security Prevents path traversal, blocks sensitive paths, rejects symlinks
|
|
60
|
-
*/
|
|
61
|
-
function sanitizePath(inputPath: string): string {
|
|
62
|
-
const resolved = resolve(inputPath);
|
|
63
|
-
|
|
64
|
-
// Block path traversal
|
|
65
|
-
if (inputPath.includes("..")) {
|
|
66
|
-
throw new Error(`Path traversal attempt: ${inputPath}`);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Block sensitive paths
|
|
70
|
-
for (const sensitive of SENSITIVE_PATHS) {
|
|
71
|
-
if (resolved.startsWith(sensitive)) {
|
|
72
|
-
throw new Error(`Access to sensitive path denied: ${inputPath}`);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Block symlinks (could point anywhere)
|
|
77
|
-
try {
|
|
78
|
-
const stat = lstatSync(resolved);
|
|
79
|
-
if (stat.isSymbolicLink()) {
|
|
80
|
-
throw new Error(`Symlinks not allowed: ${inputPath}`);
|
|
81
|
-
}
|
|
82
|
-
} catch (e) {
|
|
83
|
-
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return resolved;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Process file attachments for sending with messages
|
|
91
|
-
*/
|
|
92
|
-
export async function processAttachments(
|
|
93
|
-
filePaths: string[],
|
|
94
|
-
options: { quiet?: boolean } = {},
|
|
95
|
-
): Promise<Attachment[]> {
|
|
96
|
-
if (filePaths.length > MAX_FILES) {
|
|
97
|
-
throw new Error(`Too many files. Maximum ${MAX_FILES} files allowed.`);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const spinner = options.quiet ? null : ora("Processing attachments...").start();
|
|
101
|
-
const attachments: Attachment[] = [];
|
|
102
|
-
let totalSize = 0;
|
|
103
|
-
|
|
104
|
-
for (const filePath of filePaths) {
|
|
105
|
-
// SECURITY: Sanitize path before access
|
|
106
|
-
const safePath = sanitizePath(filePath);
|
|
107
|
-
const file = Bun.file(safePath);
|
|
108
|
-
|
|
109
|
-
if (!(await file.exists())) {
|
|
110
|
-
throw new Error(`File not found: ${filePath}`);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const size = file.size;
|
|
114
|
-
|
|
115
|
-
if (size > MAX_FILE_SIZE) {
|
|
116
|
-
throw new Error(
|
|
117
|
-
`File too large: ${filePath} (${(size / 1024 / 1024).toFixed(1)}MB). Max ${MAX_FILE_SIZE / 1024 / 1024}MB.`,
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
totalSize += size;
|
|
122
|
-
if (totalSize > MAX_TOTAL_SIZE) {
|
|
123
|
-
throw new Error(`Total attachment size exceeds ${MAX_TOTAL_SIZE / 1024 / 1024}MB limit.`);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const fileName = filePath.split("/").pop() || filePath;
|
|
127
|
-
const ext = `.${fileName.split(".").pop()?.toLowerCase() || ""}`;
|
|
128
|
-
|
|
129
|
-
// Determine if text or binary
|
|
130
|
-
const isText = TEXT_EXTENSIONS.has(ext);
|
|
131
|
-
const mimeType = file.type || (isText ? "text/plain" : "application/octet-stream");
|
|
132
|
-
|
|
133
|
-
if (spinner) {
|
|
134
|
-
spinner.text = `Processing ${fileName}...`;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
let content: string;
|
|
138
|
-
if (isText) {
|
|
139
|
-
content = await file.text();
|
|
140
|
-
} else {
|
|
141
|
-
const buffer = await file.arrayBuffer();
|
|
142
|
-
content = Buffer.from(buffer).toString("base64");
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
attachments.push({
|
|
146
|
-
name: fileName,
|
|
147
|
-
content,
|
|
148
|
-
type: isText ? "text" : "binary",
|
|
149
|
-
size,
|
|
150
|
-
mimeType,
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
spinner?.succeed(`Processed ${attachments.length} file(s)`);
|
|
155
|
-
|
|
156
|
-
return attachments;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Format attachments summary for display
|
|
161
|
-
*/
|
|
162
|
-
export function formatAttachmentsSummary(attachments: Attachment[]): string {
|
|
163
|
-
if (attachments.length === 0) return "";
|
|
164
|
-
|
|
165
|
-
const totalSize = attachments.reduce((sum, a) => sum + a.size, 0);
|
|
166
|
-
const sizeStr =
|
|
167
|
-
totalSize < 1024
|
|
168
|
-
? `${totalSize}B`
|
|
169
|
-
: totalSize < 1024 * 1024
|
|
170
|
-
? `${(totalSize / 1024).toFixed(1)}KB`
|
|
171
|
-
: `${(totalSize / 1024 / 1024).toFixed(1)}MB`;
|
|
172
|
-
|
|
173
|
-
return `${attachments.length} file(s), ${sizeStr} total`;
|
|
174
|
-
}
|