@42ailab/42plugin 0.1.0-beta.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.
@@ -0,0 +1,148 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { createWriteStream } from 'fs';
4
+ import { pipeline } from 'stream/promises';
5
+ import * as tar from 'tar';
6
+ import { createHash } from 'crypto';
7
+ import { getCacheDir } from '../config';
8
+
9
+ export async function downloadAndExtract(
10
+ url: string,
11
+ expectedChecksum: string,
12
+ fullName: string,
13
+ version: string
14
+ ): Promise<string> {
15
+ // Build cache path
16
+ const cachePath = buildCachePath(fullName, version);
17
+ await fs.mkdir(cachePath, { recursive: true });
18
+
19
+ // Download file
20
+ const tempPath = path.join(cachePath, '_download.tar.gz');
21
+ await downloadFile(url, tempPath);
22
+
23
+ // Verify checksum
24
+ const actualChecksum = await computeFileChecksum(tempPath);
25
+ const expectedHash = expectedChecksum.replace('sha256:', '');
26
+ if (actualChecksum !== expectedHash) {
27
+ await fs.rm(tempPath);
28
+ throw new Error('Checksum verification failed');
29
+ }
30
+
31
+ // Extract
32
+ await tar.extract({
33
+ file: tempPath,
34
+ cwd: cachePath,
35
+ });
36
+
37
+ // Delete temp file
38
+ await fs.rm(tempPath);
39
+
40
+ // Check if tarball extracted to a single subdirectory (common pattern)
41
+ // If so, return that subdirectory as the content path
42
+ const entries = await fs.readdir(cachePath);
43
+ if (entries.length === 1) {
44
+ const subPath = path.join(cachePath, entries[0]);
45
+ const stat = await fs.stat(subPath);
46
+ if (stat.isDirectory()) {
47
+ return subPath;
48
+ }
49
+ }
50
+
51
+ return cachePath;
52
+ }
53
+
54
+ export function buildCachePath(fullName: string, version: string): string {
55
+ // fullName formats:
56
+ // - owner/repo:plugin:type:name (capability from GitHub)
57
+ // - owner:custom:type:name (custom/curated capability)
58
+ // - owner/repo:plugin (plugin)
59
+ // - owner/plugin (simple)
60
+
61
+ const parts = fullName.split(':');
62
+ const [ownerRepo] = parts;
63
+ const [owner, repo] = ownerRepo.split('/');
64
+
65
+ if (parts.length === 4) {
66
+ const [, pluginName, capType, capName] = parts;
67
+
68
+ // Check if this is a custom capability (no slash in first part)
69
+ if (!repo) {
70
+ // owner:custom:type:name format (custom capability)
71
+ return path.join(
72
+ getCacheDir(),
73
+ owner,
74
+ pluginName, // 'custom'
75
+ capType,
76
+ capName,
77
+ version
78
+ );
79
+ }
80
+
81
+ // owner/repo:plugin:type:name format (GitHub capability)
82
+ return path.join(
83
+ getCacheDir(),
84
+ owner,
85
+ repo,
86
+ pluginName,
87
+ capType,
88
+ capName,
89
+ version
90
+ );
91
+ } else if (parts.length === 2) {
92
+ // owner/repo:plugin
93
+ const [, pluginName] = parts;
94
+ return path.join(
95
+ getCacheDir(),
96
+ owner,
97
+ repo || 'default',
98
+ pluginName,
99
+ version
100
+ );
101
+ } else {
102
+ // owner/plugin (simple format)
103
+ return path.join(
104
+ getCacheDir(),
105
+ owner,
106
+ repo || 'default',
107
+ version
108
+ );
109
+ }
110
+ }
111
+
112
+ async function downloadFile(url: string, destPath: string): Promise<void> {
113
+ const response = await fetch(url);
114
+
115
+ if (!response.ok) {
116
+ throw new Error(`Download failed: ${response.status}`);
117
+ }
118
+
119
+ if (!response.body) {
120
+ throw new Error('No response body');
121
+ }
122
+
123
+ const fileStream = createWriteStream(destPath);
124
+ // Convert Web ReadableStream to Node stream
125
+ const reader = response.body.getReader();
126
+
127
+ try {
128
+ while (true) {
129
+ const { done, value } = await reader.read();
130
+ if (done) break;
131
+ fileStream.write(value);
132
+ }
133
+ } finally {
134
+ reader.releaseLock();
135
+ fileStream.end();
136
+ }
137
+
138
+ // Wait for file to be fully written
139
+ await new Promise<void>((resolve, reject) => {
140
+ fileStream.on('finish', resolve);
141
+ fileStream.on('error', reject);
142
+ });
143
+ }
144
+
145
+ async function computeFileChecksum(filePath: string): Promise<string> {
146
+ const content = await fs.readFile(filePath);
147
+ return createHash('sha256').update(content).digest('hex');
148
+ }
@@ -0,0 +1,86 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { exec } from 'child_process';
4
+ import { promisify } from 'util';
5
+ import { platform } from '../utils/platform';
6
+
7
+ const execAsync = promisify(exec);
8
+
9
+ export async function createLink(source: string, target: string): Promise<void> {
10
+ // Normalize paths and remove trailing slashes (important for symlink to work correctly)
11
+ const normalizedSource = source.replace(/\/+$/, '');
12
+ const normalizedTarget = target.replace(/\/+$/, '');
13
+
14
+ // Ensure target directory exists
15
+ await fs.mkdir(path.dirname(normalizedTarget), { recursive: true });
16
+
17
+ // If target already exists, remove it first
18
+ try {
19
+ await fs.rm(normalizedTarget, { recursive: true, force: true });
20
+ } catch {
21
+ // Ignore
22
+ }
23
+
24
+ // Choose link strategy based on platform
25
+ if (platform.isWindows) {
26
+ await createWindowsLink(normalizedSource, normalizedTarget);
27
+ } else {
28
+ await createPosixLink(normalizedSource, normalizedTarget);
29
+ }
30
+ }
31
+
32
+ async function createPosixLink(source: string, target: string): Promise<void> {
33
+ try {
34
+ await fs.symlink(source, target, 'dir');
35
+ } catch (error) {
36
+ // symlink failed, fall back to copy
37
+ const errorMsg = error instanceof Error ? error.message : String(error);
38
+ console.warn(`Symlink failed (${errorMsg}), falling back to copy`);
39
+ await copyDir(source, target);
40
+ }
41
+ }
42
+
43
+ async function createWindowsLink(source: string, target: string): Promise<void> {
44
+ try {
45
+ // Try to create junction
46
+ await execAsync(`mklink /J "${target}" "${source}"`);
47
+ } catch {
48
+ // junction failed, fall back to copy
49
+ console.warn('Junction failed, falling back to copy');
50
+ await copyDir(source, target);
51
+ }
52
+ }
53
+
54
+ async function copyDir(source: string, target: string): Promise<void> {
55
+ await fs.mkdir(target, { recursive: true });
56
+
57
+ const entries = await fs.readdir(source, { withFileTypes: true });
58
+
59
+ for (const entry of entries) {
60
+ const srcPath = path.join(source, entry.name);
61
+ const destPath = path.join(target, entry.name);
62
+
63
+ if (entry.isDirectory()) {
64
+ await copyDir(srcPath, destPath);
65
+ } else {
66
+ await fs.copyFile(srcPath, destPath);
67
+ }
68
+ }
69
+ }
70
+
71
+ export async function removeLink(linkPath: string): Promise<void> {
72
+ try {
73
+ await fs.rm(linkPath, { recursive: true, force: true });
74
+ } catch {
75
+ // Ignore errors
76
+ }
77
+ }
78
+
79
+ export async function isSymlink(linkPath: string): Promise<boolean> {
80
+ try {
81
+ const stats = await fs.lstat(linkPath);
82
+ return stats.isSymbolicLink();
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
@@ -0,0 +1,179 @@
1
+ import path from 'path';
2
+ import { nanoid } from 'nanoid';
3
+ import { getDb } from '../db/client';
4
+ import type { Project, ProjectPlugin } from '../types/db';
5
+
6
+ export async function getProject(projectPath: string): Promise<Project | null> {
7
+ const db = getDb();
8
+ const result = await db.execute({
9
+ sql: 'SELECT * FROM projects WHERE path = ?',
10
+ args: [projectPath],
11
+ });
12
+
13
+ if (result.rows.length === 0) return null;
14
+ return result.rows[0] as unknown as Project;
15
+ }
16
+
17
+ export async function getOrCreateProject(projectPath: string): Promise<Project> {
18
+ let project = await getProject(projectPath);
19
+
20
+ if (!project) {
21
+ const db = getDb();
22
+ const id = nanoid();
23
+ const name = path.basename(projectPath);
24
+ const now = new Date().toISOString();
25
+
26
+ await db.execute({
27
+ sql: `INSERT INTO projects (id, path, name, registered_at, last_used_at)
28
+ VALUES (?, ?, ?, ?, ?)`,
29
+ args: [id, projectPath, name, now, now],
30
+ });
31
+
32
+ project = { id, path: projectPath, name, registered_at: now, last_used_at: now };
33
+ } else {
34
+ // Update last used time
35
+ const db = getDb();
36
+ await db.execute({
37
+ sql: 'UPDATE projects SET last_used_at = ? WHERE id = ?',
38
+ args: [new Date().toISOString(), project.id],
39
+ });
40
+ }
41
+
42
+ return project;
43
+ }
44
+
45
+ export async function getProjectPlugins(projectId: string): Promise<ProjectPlugin[]> {
46
+ const db = getDb();
47
+ const result = await db.execute({
48
+ sql: 'SELECT * FROM project_plugins WHERE project_id = ?',
49
+ args: [projectId],
50
+ });
51
+
52
+ return result.rows as unknown as ProjectPlugin[];
53
+ }
54
+
55
+ export async function getProjectPlugin(projectId: string, fullName: string): Promise<ProjectPlugin | null> {
56
+ const db = getDb();
57
+ const result = await db.execute({
58
+ sql: 'SELECT * FROM project_plugins WHERE project_id = ? AND full_name = ?',
59
+ args: [projectId, fullName],
60
+ });
61
+
62
+ if (result.rows.length === 0) return null;
63
+ return result.rows[0] as unknown as ProjectPlugin;
64
+ }
65
+
66
+ export async function addProjectPlugin(plugin: {
67
+ project_id: string;
68
+ full_name: string;
69
+ type: string;
70
+ version: string;
71
+ cache_path: string;
72
+ link_path: string;
73
+ source?: string;
74
+ source_list?: string;
75
+ }): Promise<void> {
76
+ const db = getDb();
77
+ const id = nanoid();
78
+ const now = new Date().toISOString();
79
+
80
+ await db.execute({
81
+ sql: `INSERT INTO project_plugins
82
+ (id, project_id, full_name, type, version, cache_path, link_path, installed_at, source, source_list)
83
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
84
+ ON CONFLICT(project_id, full_name) DO UPDATE SET
85
+ id = excluded.id,
86
+ type = excluded.type,
87
+ version = excluded.version,
88
+ cache_path = excluded.cache_path,
89
+ link_path = excluded.link_path,
90
+ installed_at = excluded.installed_at,
91
+ source = excluded.source,
92
+ source_list = excluded.source_list`,
93
+ args: [
94
+ id,
95
+ plugin.project_id,
96
+ plugin.full_name,
97
+ plugin.type,
98
+ plugin.version,
99
+ plugin.cache_path,
100
+ plugin.link_path,
101
+ now,
102
+ plugin.source || 'direct',
103
+ plugin.source_list || null,
104
+ ],
105
+ });
106
+ }
107
+
108
+ export async function removeProjectPlugin(
109
+ projectId: string,
110
+ fullName: string
111
+ ): Promise<ProjectPlugin | null> {
112
+ const plugin = await findProjectPlugin(projectId, fullName);
113
+
114
+ if (!plugin) return null;
115
+
116
+ const db = getDb();
117
+ await db.execute({
118
+ sql: 'DELETE FROM project_plugins WHERE id = ?',
119
+ args: [plugin.id],
120
+ });
121
+
122
+ return plugin;
123
+ }
124
+
125
+ /**
126
+ * Find a project plugin by various identifier formats:
127
+ * - Full name: owner:custom:type:name or owner/repo:plugin:type:name
128
+ * - Slug format: author/slug (e.g., 42plugin/wechat-downloader)
129
+ * - Simple name: wechat-downloader
130
+ */
131
+ export async function findProjectPlugin(
132
+ projectId: string,
133
+ identifier: string
134
+ ): Promise<ProjectPlugin | null> {
135
+ const db = getDb();
136
+
137
+ // 1. Try exact full_name match first
138
+ let result = await db.execute({
139
+ sql: 'SELECT * FROM project_plugins WHERE project_id = ? AND full_name = ?',
140
+ args: [projectId, identifier],
141
+ });
142
+ if (result.rows.length > 0) {
143
+ return result.rows[0] as unknown as ProjectPlugin;
144
+ }
145
+
146
+ // 2. Try matching by slug format (author/name -> find where full_name ends with :type:name)
147
+ if (identifier.includes('/') && !identifier.includes(':')) {
148
+ const [author, name] = identifier.split('/');
149
+ // Match pattern: {author}:custom:{type}:{name} or similar
150
+ result = await db.execute({
151
+ sql: `SELECT * FROM project_plugins
152
+ WHERE project_id = ?
153
+ AND (full_name LIKE ? OR full_name LIKE ?)`,
154
+ args: [projectId, `${author}:%:${name}`, `${author}/%:%:${name}`],
155
+ });
156
+ if (result.rows.length > 0) {
157
+ return result.rows[0] as unknown as ProjectPlugin;
158
+ }
159
+ }
160
+
161
+ // 3. Try matching by simple name (last part of full_name)
162
+ result = await db.execute({
163
+ sql: `SELECT * FROM project_plugins
164
+ WHERE project_id = ?
165
+ AND full_name LIKE ?`,
166
+ args: [projectId, `%:${identifier}`],
167
+ });
168
+ if (result.rows.length > 0) {
169
+ return result.rows[0] as unknown as ProjectPlugin;
170
+ }
171
+
172
+ return null;
173
+ }
174
+
175
+ export async function getAllProjects(): Promise<Project[]> {
176
+ const db = getDb();
177
+ const result = await db.execute('SELECT * FROM projects ORDER BY last_used_at DESC');
178
+ return result.rows as unknown as Project[];
179
+ }
@@ -0,0 +1,115 @@
1
+ export interface AuthStartResponse {
2
+ code: string;
3
+ auth_url: string;
4
+ expires_at: string;
5
+ poll_interval: number;
6
+ }
7
+
8
+ export interface AuthPollResponse {
9
+ status: 'pending' | 'completed';
10
+ access_token?: string;
11
+ refresh_token?: string;
12
+ expires_in?: number;
13
+ user?: {
14
+ username: string;
15
+ display_name: string | null;
16
+ role: string;
17
+ };
18
+ }
19
+
20
+ export interface UserProfile {
21
+ id: string;
22
+ username: string;
23
+ display_name: string | null;
24
+ email: string | null;
25
+ avatar_url: string | null;
26
+ role: string;
27
+ vip_expires_at: string | null;
28
+ verified: boolean;
29
+ created_at: string;
30
+ }
31
+
32
+ export interface CapabilityDownload {
33
+ full_name: string;
34
+ version: string;
35
+ type: string;
36
+ download_url: string;
37
+ checksum: string;
38
+ size_bytes: number;
39
+ is_paid: boolean;
40
+ requires_auth: boolean;
41
+ expires_at?: string;
42
+ install_path: string;
43
+ }
44
+
45
+ export interface PluginDetail {
46
+ full_name: string;
47
+ name: string;
48
+ description: string | null;
49
+ version: string;
50
+ capabilities: Array<{
51
+ full_name: string;
52
+ name: string;
53
+ type: string;
54
+ description: string | null;
55
+ }>;
56
+ }
57
+
58
+ export interface ListDownload {
59
+ list: {
60
+ full_name: string;
61
+ name: string;
62
+ description: string | null;
63
+ price_tier: string | null;
64
+ effective_price_tier: string;
65
+ };
66
+ capabilities: Array<{
67
+ full_name: string;
68
+ name: string;
69
+ type: string;
70
+ version: string;
71
+ required: boolean;
72
+ reason: string | null;
73
+ download_url: string;
74
+ checksum: string;
75
+ size_bytes: number;
76
+ install_path: string;
77
+ }>;
78
+ summary: {
79
+ total_capabilities: number;
80
+ required_count: number;
81
+ optional_count: number;
82
+ total_size_bytes: number;
83
+ expires_at: string;
84
+ };
85
+ }
86
+
87
+ export interface SearchCapability {
88
+ full_name: string;
89
+ slug: string | null;
90
+ author_username: string | null;
91
+ short_name: string; // author/slug if available, otherwise full_name
92
+ name: string;
93
+ type: string;
94
+ description: string | null;
95
+ downloads: number;
96
+ relevance: number;
97
+ }
98
+
99
+ export interface SearchList {
100
+ full_name: string;
101
+ name: string;
102
+ capability_count: number;
103
+ downloads: number;
104
+ relevance: number;
105
+ }
106
+
107
+ export interface SearchResult {
108
+ capabilities: SearchCapability[];
109
+ lists: SearchList[];
110
+ }
111
+
112
+ export interface SearchOptions {
113
+ type?: string;
114
+ per_page?: number;
115
+ }
@@ -0,0 +1,31 @@
1
+ export interface Project {
2
+ id: string;
3
+ path: string;
4
+ name: string;
5
+ registered_at: string;
6
+ last_used_at: string;
7
+ }
8
+
9
+ export interface CacheEntry {
10
+ id: string;
11
+ full_name: string;
12
+ type: string;
13
+ version: string;
14
+ cache_path: string;
15
+ checksum: string;
16
+ size_bytes: number | null;
17
+ downloaded_at: string;
18
+ }
19
+
20
+ export interface ProjectPlugin {
21
+ id: string;
22
+ project_id: string;
23
+ full_name: string;
24
+ type: string;
25
+ version: string;
26
+ cache_path: string;
27
+ link_path: string;
28
+ installed_at: string;
29
+ source: string;
30
+ source_list: string | null;
31
+ }
@@ -0,0 +1,40 @@
1
+ export class CliError extends Error {
2
+ constructor(
3
+ message: string,
4
+ public code: string,
5
+ public exitCode: number = 1
6
+ ) {
7
+ super(message);
8
+ this.name = 'CliError';
9
+ }
10
+ }
11
+
12
+ export class AuthError extends CliError {
13
+ constructor(message: string) {
14
+ super(message, 'AUTH_ERROR', 1);
15
+ }
16
+ }
17
+
18
+ export class NetworkError extends CliError {
19
+ constructor(message: string) {
20
+ super(message, 'NETWORK_ERROR', 1);
21
+ }
22
+ }
23
+
24
+ export class NotFoundError extends CliError {
25
+ constructor(resource: string) {
26
+ super(`Not found: ${resource}`, 'NOT_FOUND', 1);
27
+ }
28
+ }
29
+
30
+ export class PermissionError extends CliError {
31
+ constructor(message: string) {
32
+ super(message, 'PERMISSION_ERROR', 1);
33
+ }
34
+ }
35
+
36
+ export class ValidationError extends CliError {
37
+ constructor(message: string) {
38
+ super(message, 'VALIDATION_ERROR', 1);
39
+ }
40
+ }
@@ -0,0 +1,6 @@
1
+ export const platform = {
2
+ isWindows: process.platform === 'win32',
3
+ isMac: process.platform === 'darwin',
4
+ isLinux: process.platform === 'linux',
5
+ isPosix: process.platform !== 'win32',
6
+ };