@mindtnv/todoist-cli 0.4.0 → 0.5.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/package.json +6 -6
- package/src/api/activity.ts +8 -0
- package/src/api/client.ts +214 -0
- package/src/api/comments.ts +18 -0
- package/src/api/completed.ts +15 -0
- package/src/api/labels.ts +18 -0
- package/src/api/projects.ts +22 -0
- package/src/api/sections.ts +20 -0
- package/src/api/stats.ts +38 -0
- package/src/api/tasks.ts +34 -0
- package/src/api/types.ts +202 -0
- package/src/cli/auth.ts +40 -0
- package/src/cli/commands/task/add.ts +328 -0
- package/src/cli/commands/task/complete.ts +62 -0
- package/src/cli/commands/task/delete.ts +62 -0
- package/src/cli/commands/task/helpers.ts +289 -0
- package/src/cli/commands/task/index.ts +27 -0
- package/src/cli/commands/task/list.ts +151 -0
- package/src/cli/commands/task/move.ts +49 -0
- package/src/cli/commands/task/reopen.ts +43 -0
- package/src/cli/commands/task/show.ts +115 -0
- package/src/cli/commands/task/update.ts +122 -0
- package/src/cli/comment.ts +83 -0
- package/src/cli/completed.ts +87 -0
- package/src/cli/completion.ts +360 -0
- package/src/cli/filter.ts +115 -0
- package/src/cli/index.ts +638 -0
- package/src/cli/label.ts +120 -0
- package/src/cli/log.ts +57 -0
- package/src/cli/matrix.ts +100 -0
- package/src/cli/plugin-loader.ts +38 -0
- package/src/cli/plugin.ts +289 -0
- package/src/cli/project.ts +172 -0
- package/src/cli/review.ts +116 -0
- package/src/cli/section.ts +98 -0
- package/src/cli/stats.ts +62 -0
- package/src/cli/template.ts +89 -0
- package/src/config/index.ts +229 -0
- package/src/plugins/api-proxy.ts +70 -0
- package/src/plugins/extension-registry.ts +53 -0
- package/src/plugins/hook-registry.ts +36 -0
- package/src/plugins/loader.ts +200 -0
- package/src/plugins/marketplace-types.ts +55 -0
- package/src/plugins/marketplace.ts +576 -0
- package/src/plugins/palette-registry.ts +21 -0
- package/src/plugins/storage.ts +101 -0
- package/src/plugins/types.ts +226 -0
- package/src/plugins/view-registry.ts +19 -0
- package/src/ui/App.tsx +234 -0
- package/src/ui/components/Breadcrumb.tsx +18 -0
- package/src/ui/components/CommandPalette.tsx +237 -0
- package/src/ui/components/ConfirmDialog.tsx +28 -0
- package/src/ui/components/EditTaskModal.tsx +484 -0
- package/src/ui/components/HelpOverlay.tsx +195 -0
- package/src/ui/components/InputPrompt.tsx +109 -0
- package/src/ui/components/LabelPicker.tsx +110 -0
- package/src/ui/components/ModalManager.tsx +275 -0
- package/src/ui/components/ProjectPicker.tsx +95 -0
- package/src/ui/components/Sidebar.tsx +282 -0
- package/src/ui/components/SortMenu.tsx +77 -0
- package/src/ui/components/StatusBar.tsx +67 -0
- package/src/ui/components/TaskList.tsx +258 -0
- package/src/ui/components/TaskRow.tsx +105 -0
- package/src/ui/hooks/useKeyboardHandler.ts +291 -0
- package/src/ui/hooks/useStatusMessage.ts +32 -0
- package/src/ui/hooks/useTaskOperations.ts +558 -0
- package/src/ui/hooks/useUndoSystem.ts +218 -0
- package/src/ui/views/ActivityView.tsx +213 -0
- package/src/ui/views/CompletedView.tsx +337 -0
- package/src/ui/views/StatsView.tsx +178 -0
- package/src/ui/views/TaskDetailView.tsx +438 -0
- package/src/ui/views/TasksView.tsx +851 -0
- package/src/utils/colors.ts +27 -0
- package/src/utils/date-format.ts +54 -0
- package/src/utils/errors.ts +159 -0
- package/src/utils/exit.ts +11 -0
- package/src/utils/format.ts +46 -0
- package/src/utils/open-url.ts +9 -0
- package/src/utils/output.ts +29 -0
- package/src/utils/quick-add.ts +202 -0
- package/src/utils/resolve.ts +359 -0
- package/src/utils/sorting.ts +27 -0
- package/src/utils/validation.ts +88 -0
- package/dist/index.js +0 -11355
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
cpSync,
|
|
9
|
+
} from "fs";
|
|
10
|
+
import { execSync } from "child_process";
|
|
11
|
+
import { getConfig, saveConfig, setPluginEntry, removePluginEntry } from "../config/index.ts";
|
|
12
|
+
import type {
|
|
13
|
+
MarketplaceManifest,
|
|
14
|
+
MarketplacePluginEntry,
|
|
15
|
+
MarketplaceExternalSource,
|
|
16
|
+
MarketplaceConfig,
|
|
17
|
+
DiscoveredPlugin,
|
|
18
|
+
InstallResult,
|
|
19
|
+
UpdateResult,
|
|
20
|
+
} from "./marketplace-types.ts";
|
|
21
|
+
|
|
22
|
+
// ── Constants ──
|
|
23
|
+
|
|
24
|
+
const CONFIG_DIR = join(homedir(), ".config", "todoist-cli");
|
|
25
|
+
const MARKETPLACE_CACHE_DIR = join(CONFIG_DIR, "marketplace-cache");
|
|
26
|
+
const PLUGINS_DIR = join(CONFIG_DIR, "plugins");
|
|
27
|
+
const DEFAULT_MARKETPLACE = "github:mindtnv/todoist-cli";
|
|
28
|
+
const DEFAULT_MARKETPLACE_NAME = "todoist-cli-official";
|
|
29
|
+
|
|
30
|
+
// ── Helpers ──
|
|
31
|
+
|
|
32
|
+
function ensureDir(dir: string): void {
|
|
33
|
+
if (!existsSync(dir)) {
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseGitHubSource(source: string): { user: string; repo: string } | null {
|
|
39
|
+
if (!source.startsWith("github:")) return null;
|
|
40
|
+
const parts = source.replace("github:", "").split("/");
|
|
41
|
+
if (parts.length < 2 || !parts[0] || !parts[1]) return null;
|
|
42
|
+
return { user: parts[0], repo: parts[1] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function deriveNameFromSource(source: string): string {
|
|
46
|
+
if (source.startsWith("github:")) {
|
|
47
|
+
const parts = source.replace("github:", "").split("/");
|
|
48
|
+
return parts[parts.length - 1] ?? source;
|
|
49
|
+
}
|
|
50
|
+
// URL: use last path segment
|
|
51
|
+
try {
|
|
52
|
+
const url = new URL(source);
|
|
53
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
54
|
+
return segments[segments.length - 1] ?? source;
|
|
55
|
+
} catch {
|
|
56
|
+
// Local path: use last directory name
|
|
57
|
+
return source.split("/").pop() ?? source;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isExternalSource(source: string | MarketplaceExternalSource): source is MarketplaceExternalSource {
|
|
62
|
+
return typeof source === "object" && source !== null && "type" in source;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Marketplace Registration ──
|
|
66
|
+
|
|
67
|
+
export function getRegisteredMarketplaces(): MarketplaceConfig[] {
|
|
68
|
+
const config = getConfig();
|
|
69
|
+
const marketplaces: MarketplaceConfig[] = [];
|
|
70
|
+
|
|
71
|
+
// Always include the default marketplace
|
|
72
|
+
marketplaces.push({
|
|
73
|
+
name: DEFAULT_MARKETPLACE_NAME,
|
|
74
|
+
source: DEFAULT_MARKETPLACE,
|
|
75
|
+
autoUpdate: true,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Add user-configured marketplaces
|
|
79
|
+
const configMarketplaces = (config as Record<string, unknown>).marketplaces as
|
|
80
|
+
| Record<string, Record<string, unknown>>
|
|
81
|
+
| undefined;
|
|
82
|
+
|
|
83
|
+
if (configMarketplaces) {
|
|
84
|
+
for (const [name, entry] of Object.entries(configMarketplaces)) {
|
|
85
|
+
if (name === DEFAULT_MARKETPLACE_NAME) continue; // skip duplicate default
|
|
86
|
+
marketplaces.push({
|
|
87
|
+
name,
|
|
88
|
+
source: (entry.source as string) ?? "",
|
|
89
|
+
autoUpdate: (entry.autoUpdate as boolean) ?? true,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return marketplaces;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function addMarketplace(source: string): string {
|
|
98
|
+
const name = deriveNameFromSource(source);
|
|
99
|
+
|
|
100
|
+
if (name === DEFAULT_MARKETPLACE_NAME) {
|
|
101
|
+
throw new Error(`Cannot add marketplace with reserved name "${DEFAULT_MARKETPLACE_NAME}".`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Save to config under marketplaces.<name>
|
|
105
|
+
const config = getConfig();
|
|
106
|
+
const rawConfig = config as Record<string, unknown>;
|
|
107
|
+
if (!rawConfig.marketplaces) {
|
|
108
|
+
rawConfig.marketplaces = {};
|
|
109
|
+
}
|
|
110
|
+
const marketplaces = rawConfig.marketplaces as Record<string, Record<string, unknown>>;
|
|
111
|
+
marketplaces[name] = { source, autoUpdate: true };
|
|
112
|
+
saveConfig(config);
|
|
113
|
+
|
|
114
|
+
return name;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function removeMarketplace(name: string): void {
|
|
118
|
+
if (name === DEFAULT_MARKETPLACE_NAME) {
|
|
119
|
+
throw new Error(`Cannot remove the default marketplace "${DEFAULT_MARKETPLACE_NAME}".`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const config = getConfig();
|
|
123
|
+
const rawConfig = config as Record<string, unknown>;
|
|
124
|
+
const marketplaces = rawConfig.marketplaces as
|
|
125
|
+
| Record<string, Record<string, unknown>>
|
|
126
|
+
| undefined;
|
|
127
|
+
|
|
128
|
+
if (!marketplaces || !(name in marketplaces)) {
|
|
129
|
+
throw new Error(`Marketplace "${name}" is not registered.`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
delete marketplaces[name];
|
|
133
|
+
saveConfig(config);
|
|
134
|
+
|
|
135
|
+
// Clean up cache directory
|
|
136
|
+
const cacheDir = join(MARKETPLACE_CACHE_DIR, name);
|
|
137
|
+
if (existsSync(cacheDir)) {
|
|
138
|
+
rmSync(cacheDir, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Manifest Fetching ──
|
|
143
|
+
|
|
144
|
+
export async function fetchMarketplaceManifest(
|
|
145
|
+
config: MarketplaceConfig,
|
|
146
|
+
): Promise<MarketplaceManifest> {
|
|
147
|
+
ensureDir(MARKETPLACE_CACHE_DIR);
|
|
148
|
+
|
|
149
|
+
const github = parseGitHubSource(config.source);
|
|
150
|
+
if (github) {
|
|
151
|
+
const cacheDir = join(MARKETPLACE_CACHE_DIR, config.name);
|
|
152
|
+
|
|
153
|
+
if (existsSync(cacheDir)) {
|
|
154
|
+
// Pull latest
|
|
155
|
+
try {
|
|
156
|
+
execSync(`git -C "${cacheDir}" pull`, { stdio: "pipe" });
|
|
157
|
+
} catch {
|
|
158
|
+
// If pull fails (e.g. network), use cached version
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
// Clone
|
|
162
|
+
execSync(
|
|
163
|
+
`git clone https://github.com/${github.user}/${github.repo}.git "${cacheDir}"`,
|
|
164
|
+
{ stdio: "pipe" },
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const manifestPath = join(cacheDir, "marketplace.json");
|
|
169
|
+
if (!existsSync(manifestPath)) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Marketplace "${config.name}" does not contain a marketplace.json file.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const raw = readFileSync(manifestPath, "utf-8");
|
|
175
|
+
return JSON.parse(raw) as MarketplaceManifest;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Check if it's a URL (http/https)
|
|
179
|
+
if (config.source.startsWith("http://") || config.source.startsWith("https://")) {
|
|
180
|
+
const response = await fetch(config.source);
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Failed to fetch marketplace manifest from ${config.source}: ${response.statusText}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return (await response.json()) as MarketplaceManifest;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Local path
|
|
190
|
+
const manifestPath = join(config.source, "marketplace.json");
|
|
191
|
+
if (!existsSync(manifestPath)) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Marketplace at "${config.source}" does not contain a marketplace.json file.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const raw = readFileSync(manifestPath, "utf-8");
|
|
197
|
+
return JSON.parse(raw) as MarketplaceManifest;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Plugin Discovery ──
|
|
201
|
+
|
|
202
|
+
export async function discoverPlugins(): Promise<DiscoveredPlugin[]> {
|
|
203
|
+
const marketplaces = getRegisteredMarketplaces();
|
|
204
|
+
const discovered: DiscoveredPlugin[] = [];
|
|
205
|
+
|
|
206
|
+
// Read installed plugins from config
|
|
207
|
+
const config = getConfig();
|
|
208
|
+
const installedPlugins = config.plugins ?? {};
|
|
209
|
+
|
|
210
|
+
for (const marketplace of marketplaces) {
|
|
211
|
+
try {
|
|
212
|
+
const manifest = await fetchMarketplaceManifest(marketplace);
|
|
213
|
+
|
|
214
|
+
for (const plugin of manifest.plugins) {
|
|
215
|
+
const isInstalled = plugin.name in installedPlugins;
|
|
216
|
+
const pluginConfig = installedPlugins[plugin.name];
|
|
217
|
+
const isEnabled = isInstalled ? pluginConfig?.enabled !== false : false;
|
|
218
|
+
|
|
219
|
+
discovered.push({
|
|
220
|
+
...plugin,
|
|
221
|
+
marketplace: marketplace.name,
|
|
222
|
+
installed: isInstalled,
|
|
223
|
+
enabled: isEnabled,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
// Skip marketplaces that fail to fetch
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return discovered;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Plugin Installation ──
|
|
236
|
+
|
|
237
|
+
export async function installPlugin(
|
|
238
|
+
pluginName: string,
|
|
239
|
+
marketplaceName?: string,
|
|
240
|
+
): Promise<InstallResult> {
|
|
241
|
+
ensureDir(PLUGINS_DIR);
|
|
242
|
+
|
|
243
|
+
const allPlugins = await discoverPlugins();
|
|
244
|
+
|
|
245
|
+
// Find matching plugin
|
|
246
|
+
let candidates = allPlugins.filter((p) => p.name === pluginName);
|
|
247
|
+
if (marketplaceName) {
|
|
248
|
+
candidates = candidates.filter((p) => p.marketplace === marketplaceName);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (candidates.length === 0) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Plugin "${pluginName}" not found${marketplaceName ? ` in marketplace "${marketplaceName}"` : ""}.`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const plugin = candidates[0]!;
|
|
258
|
+
|
|
259
|
+
if (plugin.installed) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`Plugin "${pluginName}" is already installed. Use "todoist plugin remove ${pluginName}" first.`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const targetDir = join(PLUGINS_DIR, pluginName);
|
|
266
|
+
|
|
267
|
+
if (isExternalSource(plugin.source)) {
|
|
268
|
+
// External source
|
|
269
|
+
resolveExternalSource(plugin.source, targetDir);
|
|
270
|
+
} else {
|
|
271
|
+
// String source
|
|
272
|
+
const source = plugin.source;
|
|
273
|
+
if (source.startsWith("./") || source.startsWith("../")) {
|
|
274
|
+
// Relative path within marketplace cache dir
|
|
275
|
+
const cacheDir = join(MARKETPLACE_CACHE_DIR, plugin.marketplace);
|
|
276
|
+
const sourcePath = join(cacheDir, source);
|
|
277
|
+
|
|
278
|
+
if (!existsSync(sourcePath)) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`Plugin source path not found: ${sourcePath}`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
ensureDir(targetDir);
|
|
285
|
+
cpSync(sourcePath, targetDir, { recursive: true });
|
|
286
|
+
} else if (source.startsWith("github:")) {
|
|
287
|
+
const github = parseGitHubSource(source);
|
|
288
|
+
if (!github) throw new Error(`Invalid GitHub source: ${source}`);
|
|
289
|
+
execSync(
|
|
290
|
+
`git clone https://github.com/${github.user}/${github.repo}.git "${targetDir}"`,
|
|
291
|
+
{ stdio: "pipe" },
|
|
292
|
+
);
|
|
293
|
+
} else {
|
|
294
|
+
// Treat as local path
|
|
295
|
+
if (!existsSync(source)) {
|
|
296
|
+
throw new Error(`Plugin source path not found: ${source}`);
|
|
297
|
+
}
|
|
298
|
+
ensureDir(targetDir);
|
|
299
|
+
cpSync(source, targetDir, { recursive: true });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Install dependencies if package.json exists
|
|
304
|
+
if (existsSync(join(targetDir, "package.json"))) {
|
|
305
|
+
try {
|
|
306
|
+
execSync(`cd "${targetDir}" && bun install`, { stdio: "pipe" });
|
|
307
|
+
} catch {
|
|
308
|
+
try {
|
|
309
|
+
execSync(`cd "${targetDir}" && npm install`, { stdio: "pipe" });
|
|
310
|
+
} catch {
|
|
311
|
+
// Dependencies installation failed, but plugin files are in place
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Register in config
|
|
317
|
+
setPluginEntry(pluginName, {
|
|
318
|
+
source: `${pluginName}@${plugin.marketplace}`,
|
|
319
|
+
enabled: true,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
name: pluginName,
|
|
324
|
+
version: plugin.version ?? "unknown",
|
|
325
|
+
marketplace: plugin.marketplace,
|
|
326
|
+
description: plugin.description,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function resolveExternalSource(
|
|
331
|
+
source: MarketplaceExternalSource,
|
|
332
|
+
targetDir: string,
|
|
333
|
+
): void {
|
|
334
|
+
switch (source.type) {
|
|
335
|
+
case "github": {
|
|
336
|
+
if (!source.repo) throw new Error("GitHub source requires a 'repo' field.");
|
|
337
|
+
const ref = source.ref ? ` --branch ${source.ref}` : "";
|
|
338
|
+
execSync(
|
|
339
|
+
`git clone https://github.com/${source.repo}.git${ref} "${targetDir}"`,
|
|
340
|
+
{ stdio: "pipe" },
|
|
341
|
+
);
|
|
342
|
+
if (source.sha) {
|
|
343
|
+
execSync(`git -C "${targetDir}" checkout ${source.sha}`, {
|
|
344
|
+
stdio: "pipe",
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case "git": {
|
|
350
|
+
if (!source.url) throw new Error("Git source requires a 'url' field.");
|
|
351
|
+
const ref = source.ref ? ` --branch ${source.ref}` : "";
|
|
352
|
+
execSync(`git clone ${source.url}${ref} "${targetDir}"`, {
|
|
353
|
+
stdio: "pipe",
|
|
354
|
+
});
|
|
355
|
+
if (source.sha) {
|
|
356
|
+
execSync(`git -C "${targetDir}" checkout ${source.sha}`, {
|
|
357
|
+
stdio: "pipe",
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
case "npm": {
|
|
363
|
+
if (!source.package) throw new Error("npm source requires a 'package' field.");
|
|
364
|
+
mkdirSync(targetDir, { recursive: true });
|
|
365
|
+
execSync(
|
|
366
|
+
`cd "${targetDir}" && npm init -y && npm install ${source.package}`,
|
|
367
|
+
{ stdio: "pipe" },
|
|
368
|
+
);
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
default:
|
|
372
|
+
throw new Error(`Unknown source type: ${(source as MarketplaceExternalSource).type}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── Plugin Removal ──
|
|
377
|
+
|
|
378
|
+
export function removePlugin(name: string): void {
|
|
379
|
+
const targetDir = join(PLUGINS_DIR, name);
|
|
380
|
+
if (existsSync(targetDir)) {
|
|
381
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
removePluginEntry(name);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Plugin Update ──
|
|
388
|
+
|
|
389
|
+
export async function updatePlugin(name: string): Promise<UpdateResult> {
|
|
390
|
+
const config = getConfig();
|
|
391
|
+
const plugins = config.plugins;
|
|
392
|
+
|
|
393
|
+
if (!plugins?.[name]) {
|
|
394
|
+
throw new Error(`Plugin "${name}" is not installed.`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const pluginEntry = plugins[name]!;
|
|
398
|
+
const sourceStr = pluginEntry.source as string | undefined;
|
|
399
|
+
|
|
400
|
+
if (!sourceStr) {
|
|
401
|
+
return { name, updated: false, message: "No source information found" };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Parse "pluginName@marketplaceName" format
|
|
405
|
+
const atIndex = sourceStr.lastIndexOf("@");
|
|
406
|
+
const marketplaceName = atIndex > 0 ? sourceStr.substring(atIndex + 1) : undefined;
|
|
407
|
+
|
|
408
|
+
if (!marketplaceName) {
|
|
409
|
+
return { name, updated: false, message: "Cannot determine marketplace for plugin" };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Refresh marketplace cache
|
|
413
|
+
await refreshMarketplaceCache(marketplaceName);
|
|
414
|
+
|
|
415
|
+
// Find plugin in manifest
|
|
416
|
+
const marketplaces = getRegisteredMarketplaces();
|
|
417
|
+
const marketplace = marketplaces.find((m) => m.name === marketplaceName);
|
|
418
|
+
if (!marketplace) {
|
|
419
|
+
return { name, updated: false, message: `Marketplace "${marketplaceName}" not found` };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
let manifest: MarketplaceManifest;
|
|
423
|
+
try {
|
|
424
|
+
manifest = await fetchMarketplaceManifest(marketplace);
|
|
425
|
+
} catch {
|
|
426
|
+
return { name, updated: false, message: `Failed to fetch marketplace "${marketplaceName}"` };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const pluginManifest = manifest.plugins.find((p) => p.name === name);
|
|
430
|
+
if (!pluginManifest) {
|
|
431
|
+
return { name, updated: false, message: `Plugin "${name}" no longer in marketplace` };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const oldVersion = (pluginEntry.version as string | undefined) ?? "unknown";
|
|
435
|
+
const newVersion = pluginManifest.version ?? "unknown";
|
|
436
|
+
const targetDir = join(PLUGINS_DIR, name);
|
|
437
|
+
|
|
438
|
+
if (isExternalSource(pluginManifest.source)) {
|
|
439
|
+
// For external sources with github type, git pull
|
|
440
|
+
if (pluginManifest.source.type === "github" && existsSync(join(targetDir, ".git"))) {
|
|
441
|
+
execSync(`git -C "${targetDir}" pull`, { stdio: "pipe" });
|
|
442
|
+
} else {
|
|
443
|
+
// Re-install: remove and clone again
|
|
444
|
+
if (existsSync(targetDir)) {
|
|
445
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
446
|
+
}
|
|
447
|
+
resolveExternalSource(pluginManifest.source, targetDir);
|
|
448
|
+
}
|
|
449
|
+
} else {
|
|
450
|
+
const source = pluginManifest.source;
|
|
451
|
+
if (source.startsWith("./") || source.startsWith("../")) {
|
|
452
|
+
// Re-copy from marketplace cache
|
|
453
|
+
const cacheDir = join(MARKETPLACE_CACHE_DIR, marketplaceName);
|
|
454
|
+
const sourcePath = join(cacheDir, source);
|
|
455
|
+
|
|
456
|
+
if (existsSync(targetDir)) {
|
|
457
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
458
|
+
}
|
|
459
|
+
ensureDir(targetDir);
|
|
460
|
+
cpSync(sourcePath, targetDir, { recursive: true });
|
|
461
|
+
} else if (existsSync(join(targetDir, ".git"))) {
|
|
462
|
+
execSync(`git -C "${targetDir}" pull`, { stdio: "pipe" });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Re-install dependencies if needed
|
|
467
|
+
if (existsSync(join(targetDir, "package.json"))) {
|
|
468
|
+
try {
|
|
469
|
+
execSync(`cd "${targetDir}" && bun install`, { stdio: "pipe" });
|
|
470
|
+
} catch {
|
|
471
|
+
try {
|
|
472
|
+
execSync(`cd "${targetDir}" && npm install`, { stdio: "pipe" });
|
|
473
|
+
} catch {
|
|
474
|
+
// Best effort
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Update config version
|
|
480
|
+
setPluginEntry(name, {
|
|
481
|
+
...pluginEntry,
|
|
482
|
+
version: newVersion,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const updated = oldVersion !== newVersion;
|
|
486
|
+
return {
|
|
487
|
+
name,
|
|
488
|
+
updated,
|
|
489
|
+
oldVersion,
|
|
490
|
+
newVersion,
|
|
491
|
+
message: updated
|
|
492
|
+
? `Updated from ${oldVersion} to ${newVersion}`
|
|
493
|
+
: "Already at latest version",
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export async function updateAllPlugins(): Promise<UpdateResult[]> {
|
|
498
|
+
const config = getConfig();
|
|
499
|
+
const plugins = config.plugins;
|
|
500
|
+
if (!plugins) return [];
|
|
501
|
+
|
|
502
|
+
const results: UpdateResult[] = [];
|
|
503
|
+
for (const name of Object.keys(plugins)) {
|
|
504
|
+
try {
|
|
505
|
+
const result = await updatePlugin(name);
|
|
506
|
+
results.push(result);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
results.push({
|
|
509
|
+
name,
|
|
510
|
+
updated: false,
|
|
511
|
+
message: err instanceof Error ? err.message : "Unknown error",
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return results;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ── Cache Management ──
|
|
520
|
+
|
|
521
|
+
export async function refreshMarketplaceCache(name?: string): Promise<void> {
|
|
522
|
+
const marketplaces = getRegisteredMarketplaces();
|
|
523
|
+
|
|
524
|
+
const targets = name
|
|
525
|
+
? marketplaces.filter((m) => m.name === name)
|
|
526
|
+
: marketplaces;
|
|
527
|
+
|
|
528
|
+
for (const marketplace of targets) {
|
|
529
|
+
const github = parseGitHubSource(marketplace.source);
|
|
530
|
+
if (!github) continue;
|
|
531
|
+
|
|
532
|
+
const cacheDir = join(MARKETPLACE_CACHE_DIR, marketplace.name);
|
|
533
|
+
|
|
534
|
+
if (existsSync(cacheDir)) {
|
|
535
|
+
try {
|
|
536
|
+
execSync(`git -C "${cacheDir}" pull`, { stdio: "pipe" });
|
|
537
|
+
} catch {
|
|
538
|
+
// Network error — use existing cache
|
|
539
|
+
}
|
|
540
|
+
} else {
|
|
541
|
+
try {
|
|
542
|
+
execSync(
|
|
543
|
+
`git clone https://github.com/${github.user}/${github.repo}.git "${cacheDir}"`,
|
|
544
|
+
{ stdio: "pipe" },
|
|
545
|
+
);
|
|
546
|
+
} catch {
|
|
547
|
+
// Clone failed — skip
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ── Enable / Disable ──
|
|
554
|
+
|
|
555
|
+
export function enablePlugin(name: string): void {
|
|
556
|
+
const config = getConfig();
|
|
557
|
+
const plugins = config.plugins;
|
|
558
|
+
if (!plugins?.[name]) {
|
|
559
|
+
throw new Error(`Plugin "${name}" is not installed.`);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const entry = plugins[name]!;
|
|
563
|
+
// Remove the enabled: false flag (enabled by default)
|
|
564
|
+
const { enabled: _enabled, ...rest } = entry;
|
|
565
|
+
setPluginEntry(name, rest);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function disablePlugin(name: string): void {
|
|
569
|
+
const config = getConfig();
|
|
570
|
+
const plugins = config.plugins;
|
|
571
|
+
if (!plugins?.[name]) {
|
|
572
|
+
throw new Error(`Plugin "${name}" is not installed.`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
setPluginEntry(name, { ...plugins[name]!, enabled: false });
|
|
576
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PaletteCommandDefinition, PaletteRegistry } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export function createPaletteRegistry(): PaletteRegistry {
|
|
4
|
+
const commands: PaletteCommandDefinition[] = [];
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
addCommands(newCommands: PaletteCommandDefinition[]) {
|
|
8
|
+
for (const cmd of newCommands) {
|
|
9
|
+
if (commands.some(c => c.label === cmd.label)) {
|
|
10
|
+
console.warn(`[plugins] Palette command "${cmd.label}" already registered, skipping`);
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
commands.push(cmd);
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
getCommands() {
|
|
18
|
+
return [...commands];
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { mkdirSync, existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { Database } from "bun:sqlite";
|
|
4
|
+
import type { PluginStorage } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export interface PluginStorageWithClose extends PluginStorage {
|
|
7
|
+
close(): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createPluginStorage(dataDir: string): PluginStorageWithClose {
|
|
11
|
+
if (!existsSync(dataDir)) {
|
|
12
|
+
mkdirSync(dataDir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const db = new Database(join(dataDir, "data.db"));
|
|
16
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
17
|
+
|
|
18
|
+
db.exec(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS kv (
|
|
20
|
+
key TEXT PRIMARY KEY,
|
|
21
|
+
value TEXT
|
|
22
|
+
)
|
|
23
|
+
`);
|
|
24
|
+
db.exec(`
|
|
25
|
+
CREATE TABLE IF NOT EXISTS task_data (
|
|
26
|
+
task_id TEXT,
|
|
27
|
+
key TEXT,
|
|
28
|
+
value TEXT,
|
|
29
|
+
PRIMARY KEY (task_id, key)
|
|
30
|
+
)
|
|
31
|
+
`);
|
|
32
|
+
|
|
33
|
+
// Prepared statements
|
|
34
|
+
const kvGet = db.prepare("SELECT value FROM kv WHERE key = ?");
|
|
35
|
+
const kvSet = db.prepare(
|
|
36
|
+
"INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)",
|
|
37
|
+
);
|
|
38
|
+
const kvDelete = db.prepare("DELETE FROM kv WHERE key = ?");
|
|
39
|
+
const kvListAll = db.prepare("SELECT key FROM kv");
|
|
40
|
+
const kvListPrefix = db.prepare("SELECT key FROM kv WHERE key LIKE ?");
|
|
41
|
+
|
|
42
|
+
const tdGet = db.prepare(
|
|
43
|
+
"SELECT value FROM task_data WHERE task_id = ? AND key = ?",
|
|
44
|
+
);
|
|
45
|
+
const tdSet = db.prepare(
|
|
46
|
+
"INSERT OR REPLACE INTO task_data (task_id, key, value) VALUES (?, ?, ?)",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
async get<T>(key: string): Promise<T | null> {
|
|
51
|
+
const row = kvGet.get(key) as { value: string } | null;
|
|
52
|
+
if (!row) return null;
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(row.value) as T;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async set<T>(key: string, value: T): Promise<void> {
|
|
61
|
+
kvSet.run(key, JSON.stringify(value));
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async delete(key: string): Promise<void> {
|
|
65
|
+
kvDelete.run(key);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async list(prefix?: string): Promise<string[]> {
|
|
69
|
+
if (!prefix) {
|
|
70
|
+
const rows = kvListAll.all() as { key: string }[];
|
|
71
|
+
return rows.map((r) => r.key);
|
|
72
|
+
}
|
|
73
|
+
// Escape % and _ in the prefix for LIKE, then append %
|
|
74
|
+
const escaped = prefix.replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
75
|
+
const rows = kvListPrefix.all(`${escaped}%`) as { key: string }[];
|
|
76
|
+
return rows.map((r) => r.key);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async getTaskData<T>(taskId: string, key: string): Promise<T | null> {
|
|
80
|
+
const row = tdGet.get(taskId, key) as { value: string } | null;
|
|
81
|
+
if (!row) return null;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(row.value) as T;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async setTaskData<T>(
|
|
90
|
+
taskId: string,
|
|
91
|
+
key: string,
|
|
92
|
+
value: T,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
tdSet.run(taskId, key, JSON.stringify(value));
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
close() {
|
|
98
|
+
db.close();
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|