@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.
@@ -1,105 +0,0 @@
1
- // $EDITOR integration with security mitigations
2
- // See plan: Security review identified command injection and temp file risks
3
-
4
- import { randomUUID } from "node:crypto";
5
- import { chmodSync, lstatSync, unlinkSync, writeFileSync } from "node:fs";
6
- import { tmpdir } from "node:os";
7
- import { basename, join } from "node:path";
8
-
9
- // SECURITY: Allowlist of safe editors
10
- const ALLOWED_EDITORS = new Set([
11
- "vi",
12
- "vim",
13
- "nvim",
14
- "nano",
15
- "emacs",
16
- "code",
17
- "subl",
18
- "atom",
19
- "micro",
20
- "helix",
21
- "joe",
22
- "pico",
23
- ]);
24
-
25
- /**
26
- * Get a secure editor command
27
- * @security Validates against allowlist and rejects shell metacharacters
28
- */
29
- function getSecureEditor(): string {
30
- const rawEditor = process.env.EDITOR || process.env.VISUAL || "vi";
31
- const firstPart = rawEditor.split(" ")[0] || "vi";
32
- const editorName = basename(firstPart);
33
-
34
- if (!ALLOWED_EDITORS.has(editorName)) {
35
- console.warn(`Warning: $EDITOR '${rawEditor}' not in allowlist, using 'vi'`);
36
- return "vi";
37
- }
38
-
39
- // SECURITY: Reject shell metacharacters
40
- if (/[;&|`$]/.test(rawEditor)) {
41
- throw new Error("$EDITOR contains shell metacharacters");
42
- }
43
-
44
- return rawEditor;
45
- }
46
-
47
- export interface EditorResult {
48
- content: string;
49
- changed: boolean;
50
- }
51
-
52
- /**
53
- * Open content in $EDITOR for editing
54
- * @security Uses secure temp paths, restrictive permissions, and secure cleanup
55
- */
56
- export async function editInEditor(
57
- initialContent: string,
58
- options: { extension?: string; title?: string } = {},
59
- ): Promise<EditorResult> {
60
- const { extension = ".md" } = options;
61
-
62
- // SECURITY: Unpredictable temp path
63
- const tempPath = join(tmpdir(), `spiral-${randomUUID()}${extension}`);
64
-
65
- try {
66
- // SECURITY: Write with restrictive permissions (owner read/write only)
67
- writeFileSync(tempPath, initialContent, { mode: 0o600 });
68
- chmodSync(tempPath, 0o600);
69
-
70
- const editor = getSecureEditor();
71
-
72
- // Get mtime before edit for change detection
73
- const statBefore = lstatSync(tempPath);
74
- const mtimeBefore = statBefore.mtimeMs;
75
-
76
- // Split editor command (handles "vim -O" style editors)
77
- const editorParts = editor.split(" ");
78
- const editorPath = editorParts[0] || "vi";
79
- const editorArgs = [...editorParts.slice(1), tempPath];
80
-
81
- // Use Bun.spawn with stdio inheritance for interactive editing
82
- const proc = Bun.spawn([editorPath, ...editorArgs], {
83
- stdin: "inherit",
84
- stdout: "inherit",
85
- stderr: "inherit",
86
- });
87
- await proc.exited;
88
-
89
- // Fast change detection via mtime
90
- const statAfter = lstatSync(tempPath);
91
- const changed = statAfter.mtimeMs !== mtimeBefore;
92
- const content = changed ? await Bun.file(tempPath).text() : initialContent;
93
-
94
- return { content, changed };
95
- } finally {
96
- // SECURITY: Secure cleanup - overwrite before deletion
97
- try {
98
- const size = initialContent.length;
99
- writeFileSync(tempPath, Buffer.alloc(size, 0));
100
- unlinkSync(tempPath);
101
- } catch {
102
- /* best effort cleanup */
103
- }
104
- }
105
- }
@@ -1,208 +0,0 @@
1
- // Draft management commands
2
- import chalk from "chalk";
3
- import ora from "ora";
4
- import {
5
- fetchDraft,
6
- fetchDraftVersions,
7
- fetchSessionDrafts,
8
- restoreDraftVersion,
9
- updateDraft,
10
- } from "../api";
11
- import { theme } from "../theme";
12
- import { editInEditor } from "./editor";
13
-
14
- export interface DraftCommandOptions {
15
- json?: boolean;
16
- limit?: number;
17
- }
18
-
19
- /**
20
- * List all drafts in a session
21
- */
22
- export async function listDrafts(sessionId: string, options: DraftCommandOptions): Promise<void> {
23
- const spinner = ora("Fetching drafts...").start();
24
-
25
- try {
26
- const drafts = await fetchSessionDrafts(sessionId);
27
- spinner.succeed("Drafts loaded");
28
-
29
- if (options.json) {
30
- console.log(JSON.stringify({ success: true, drafts }));
31
- return;
32
- }
33
-
34
- if (drafts.length === 0) {
35
- console.log(theme.dim("No drafts in this session."));
36
- return;
37
- }
38
-
39
- console.log(theme.heading("\nDrafts:\n"));
40
- for (const draft of drafts) {
41
- const preview = draft.content?.slice(0, 60) || "";
42
- console.log(`${theme.dim(draft.id.slice(0, 8))} ${chalk.white(draft.title || "Untitled")}`);
43
- console.log(` ${theme.dim(preview)}${preview.length >= 60 ? "..." : ""}\n`);
44
- }
45
- } catch (error) {
46
- spinner.fail("Failed to fetch drafts");
47
- throw error;
48
- }
49
- }
50
-
51
- /**
52
- * View a single draft
53
- */
54
- export async function viewDraft(
55
- sessionId: string,
56
- draftId: string,
57
- options: DraftCommandOptions,
58
- ): Promise<void> {
59
- const spinner = ora("Loading draft...").start();
60
-
61
- try {
62
- const draft = await fetchDraft(sessionId, draftId);
63
- spinner.succeed("Draft loaded");
64
-
65
- if (options.json) {
66
- console.log(JSON.stringify({ success: true, draft }));
67
- return;
68
- }
69
-
70
- console.log(theme.heading(`\n${draft.title || "Untitled Draft"}\n`));
71
- console.log(draft.content);
72
- console.log(theme.dim(`\nID: ${draft.id}`));
73
- console.log(theme.dim(`Updated: ${new Date(draft.updated_at).toLocaleString()}`));
74
- } catch (error) {
75
- spinner.fail("Failed to load draft");
76
- throw error;
77
- }
78
- }
79
-
80
- /**
81
- * Edit a draft in $EDITOR
82
- */
83
- export async function editDraft(
84
- sessionId: string,
85
- draftId: string,
86
- options: DraftCommandOptions,
87
- ): Promise<void> {
88
- const spinner = ora("Loading draft for editing...").start();
89
-
90
- try {
91
- const draft = await fetchDraft(sessionId, draftId);
92
- spinner.stop();
93
-
94
- console.log(theme.info(`Opening ${draft.title || "draft"} in editor...\n`));
95
-
96
- const { content, changed } = await editInEditor(draft.content, {
97
- title: draft.title || "draft",
98
- extension: ".md",
99
- });
100
-
101
- if (!changed) {
102
- console.log(theme.dim("No changes made."));
103
- return;
104
- }
105
-
106
- const saveSpinner = ora("Saving changes...").start();
107
- const updated = await updateDraft(sessionId, draftId, content);
108
- saveSpinner.succeed("Draft saved");
109
-
110
- if (options.json) {
111
- console.log(JSON.stringify({ success: true, draft: updated }));
112
- }
113
- } catch (error) {
114
- spinner.fail("Failed to edit draft");
115
- throw error;
116
- }
117
- }
118
-
119
- /**
120
- * Update draft content directly (agent-native, bypasses $EDITOR)
121
- */
122
- export async function updateDraftContent(
123
- sessionId: string,
124
- draftId: string,
125
- content: string,
126
- options: DraftCommandOptions & { title?: string },
127
- ): Promise<void> {
128
- const spinner = ora("Updating draft...").start();
129
-
130
- try {
131
- const updated = await updateDraft(sessionId, draftId, content, options.title);
132
- spinner.succeed("Draft updated");
133
-
134
- if (options.json) {
135
- console.log(JSON.stringify({ success: true, draft: updated }));
136
- } else {
137
- console.log(theme.success(`Updated draft ${draftId.slice(0, 8)}`));
138
- }
139
- } catch (error) {
140
- spinner.fail("Failed to update draft");
141
- throw error;
142
- }
143
- }
144
-
145
- /**
146
- * List version history for a draft
147
- */
148
- export async function listVersions(
149
- sessionId: string,
150
- draftId: string,
151
- options: DraftCommandOptions,
152
- ): Promise<void> {
153
- const spinner = ora("Fetching version history...").start();
154
-
155
- try {
156
- const versions = await fetchDraftVersions(sessionId, draftId);
157
- spinner.succeed("Versions loaded");
158
-
159
- if (options.json) {
160
- console.log(JSON.stringify({ success: true, versions }));
161
- return;
162
- }
163
-
164
- console.log(theme.heading(`\nVersion History (${versions.length} versions):\n`));
165
-
166
- const limit = options.limit || 10;
167
- for (const version of versions.slice(0, limit)) {
168
- const date = new Date(version.created_at).toLocaleString();
169
- const sizeKb = (version.content.length / 1024).toFixed(1);
170
- const by = version.created_by === "llm" ? theme.assistant("AI") : theme.user("You");
171
-
172
- console.log(`${theme.dim(version.id.slice(0, 8))} ${by} - ${date} (${sizeKb}kb)`);
173
- }
174
-
175
- if (versions.length > limit) {
176
- console.log(theme.dim(`\n... and ${versions.length - limit} more`));
177
- }
178
- } catch (error) {
179
- spinner.fail("Failed to fetch versions");
180
- throw error;
181
- }
182
- }
183
-
184
- /**
185
- * Restore a draft to a specific version
186
- */
187
- export async function restoreVersion(
188
- sessionId: string,
189
- draftId: string,
190
- versionId: string,
191
- options: DraftCommandOptions,
192
- ): Promise<void> {
193
- const spinner = ora("Restoring version...").start();
194
-
195
- try {
196
- const draft = await restoreDraftVersion(sessionId, draftId, versionId);
197
- spinner.succeed("Version restored");
198
-
199
- if (options.json) {
200
- console.log(JSON.stringify({ success: true, draft }));
201
- } else {
202
- console.log(theme.success(`Restored to version ${versionId.slice(0, 8)}`));
203
- }
204
- } catch (error) {
205
- spinner.fail("Failed to restore version");
206
- throw error;
207
- }
208
- }
@@ -1,130 +0,0 @@
1
- // Scratchpad/notes management with local persistence
2
- import { confirm } from "@inquirer/prompts";
3
- import chalk from "chalk";
4
- import { type NoteItem, config } from "../config";
5
- import { theme } from "../theme";
6
-
7
- export interface NoteCommandOptions {
8
- json?: boolean;
9
- force?: boolean;
10
- }
11
-
12
- /**
13
- * Add a new note
14
- */
15
- export function addNote(
16
- content: string,
17
- feedback?: string,
18
- options: NoteCommandOptions = {},
19
- ): void {
20
- const notes = config.get("notes") || [];
21
-
22
- const note: NoteItem = {
23
- id: `note-${Date.now()}`,
24
- content,
25
- feedback,
26
- createdAt: new Date().toISOString(),
27
- };
28
-
29
- notes.push(note);
30
- config.set("notes", notes);
31
-
32
- if (options.json) {
33
- console.log(JSON.stringify({ success: true, note }));
34
- } else {
35
- console.log(
36
- theme.success(`Note added: "${content.slice(0, 40)}${content.length > 40 ? "..." : ""}"`),
37
- );
38
- }
39
- }
40
-
41
- /**
42
- * List all notes
43
- */
44
- export function listNotes(options: NoteCommandOptions): void {
45
- const notes = config.get("notes") || [];
46
-
47
- if (options.json) {
48
- console.log(JSON.stringify({ success: true, notes }));
49
- return;
50
- }
51
-
52
- if (notes.length === 0) {
53
- console.log(theme.dim("No notes. Use /note <text> to add one."));
54
- return;
55
- }
56
-
57
- console.log(theme.heading(`\nNotes (${notes.length}):\n`));
58
-
59
- for (const note of notes) {
60
- const date = new Date(note.createdAt).toLocaleString();
61
- console.log(`${theme.dim(note.id.slice(0, 12))} ${chalk.white(note.content)}`);
62
- if (note.feedback) {
63
- console.log(` ${theme.info("Feedback:")} ${note.feedback}`);
64
- }
65
- console.log(` ${theme.dim(date)}\n`);
66
- }
67
- }
68
-
69
- /**
70
- * Clear all notes
71
- */
72
- export async function clearNotes(options: NoteCommandOptions): Promise<void> {
73
- const notes = config.get("notes") || [];
74
-
75
- if (notes.length === 0) {
76
- console.log(theme.dim("No notes to clear."));
77
- return;
78
- }
79
-
80
- if (!options.force && !options.json) {
81
- const proceed = await confirm({
82
- message: `Delete all ${notes.length} notes?`,
83
- default: false,
84
- });
85
- if (!proceed) {
86
- console.log(theme.dim("Cancelled."));
87
- return;
88
- }
89
- }
90
-
91
- config.set("notes", []);
92
-
93
- if (options.json) {
94
- console.log(JSON.stringify({ success: true, cleared: notes.length }));
95
- } else {
96
- console.log(theme.success(`Cleared ${notes.length} notes.`));
97
- }
98
- }
99
-
100
- /**
101
- * Remove a specific note by ID
102
- */
103
- export function removeNote(noteId: string, options: NoteCommandOptions): void {
104
- const notes = config.get("notes") || [];
105
- const index = notes.findIndex((n: NoteItem) => n.id === noteId || n.id.startsWith(noteId));
106
-
107
- if (index === -1) {
108
- throw new Error(`Note not found: ${noteId}`);
109
- }
110
-
111
- const [removed] = notes.splice(index, 1);
112
- config.set("notes", notes);
113
-
114
- if (options.json) {
115
- console.log(JSON.stringify({ success: true, removed }));
116
- } else if (removed) {
117
- console.log(theme.dim(`Removed note: "${removed.content.slice(0, 30)}..."`));
118
- }
119
- }
120
-
121
- /**
122
- * Get notes formatted for API submission
123
- */
124
- export function getNotesForApi(): Array<{ content: string; feedback: string }> {
125
- const notes = config.get("notes") || [];
126
- return notes.map((n: NoteItem) => ({
127
- content: n.content,
128
- feedback: n.feedback || "",
129
- }));
130
- }
@@ -1,45 +0,0 @@
1
- // Writing styles management
2
- import chalk from "chalk";
3
- import ora from "ora";
4
- import { fetchWritingStyles } from "../api";
5
- import { theme } from "../theme";
6
-
7
- export interface StyleCommandOptions {
8
- json?: boolean;
9
- }
10
-
11
- /**
12
- * List all available writing styles
13
- */
14
- export async function listStyles(options: StyleCommandOptions): Promise<void> {
15
- const spinner = ora("Fetching writing styles...").start();
16
-
17
- try {
18
- const styles = await fetchWritingStyles();
19
- spinner.succeed("Styles loaded");
20
-
21
- if (options.json) {
22
- console.log(JSON.stringify({ success: true, styles }));
23
- return;
24
- }
25
-
26
- if (styles.length === 0) {
27
- console.log(theme.dim("No writing styles found. Create one in the web app."));
28
- return;
29
- }
30
-
31
- console.log(theme.heading("\nWriting Styles:\n"));
32
- for (const style of styles) {
33
- console.log(`${theme.dim(style.id.slice(0, 8))} ${chalk.white(style.name)}`);
34
- if (style.description) {
35
- console.log(
36
- ` ${theme.dim(style.description.slice(0, 80))}${style.description.length > 80 ? "..." : ""}`,
37
- );
38
- }
39
- console.log();
40
- }
41
- } catch (error) {
42
- spinner.fail("Failed to fetch styles");
43
- throw error;
44
- }
45
- }
@@ -1,33 +0,0 @@
1
- // Diff display utilities
2
- import chalk from "chalk";
3
- import { theme } from "../theme";
4
-
5
- /**
6
- * Display inline diff (before/after stacked)
7
- */
8
- export function displayInlineDiff(before: string, after: string): void {
9
- console.log(theme.heading("\n--- Before ---"));
10
- console.log(chalk.red(before));
11
- console.log(theme.heading("\n--- After ---"));
12
- console.log(chalk.green(after));
13
- console.log();
14
- }
15
-
16
- /**
17
- * Display side-by-side diff
18
- */
19
- export function displaySideBySideDiff(before: string, after: string, width = 40): void {
20
- const beforeLines = before.split("\n");
21
- const afterLines = after.split("\n");
22
- const maxLines = Math.max(beforeLines.length, afterLines.length);
23
-
24
- console.log(theme.heading(`\n${"Before".padEnd(width)} | After`));
25
- console.log(`${"-".repeat(width)} | ${"-".repeat(width)}`);
26
-
27
- for (let i = 0; i < maxLines; i++) {
28
- const b = (beforeLines[i] || "").slice(0, width).padEnd(width);
29
- const a = (afterLines[i] || "").slice(0, width);
30
- console.log(`${chalk.red(b)} | ${chalk.green(a)}`);
31
- }
32
- console.log();
33
- }