@42ailab/42plugin 0.1.0-beta.1 → 0.1.5
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 +211 -68
- package/package.json +12 -7
- package/src/api.ts +447 -0
- package/src/cli.ts +39 -16
- package/src/commands/auth.ts +83 -69
- package/src/commands/check.ts +118 -0
- package/src/commands/completion.ts +210 -0
- package/src/commands/index.ts +13 -0
- package/src/commands/install-helper.ts +71 -0
- package/src/commands/install.ts +219 -300
- package/src/commands/list.ts +42 -66
- package/src/commands/publish.ts +121 -0
- package/src/commands/search.ts +89 -85
- package/src/commands/setup.ts +158 -0
- package/src/commands/uninstall.ts +53 -44
- package/src/config.ts +27 -36
- package/src/db.ts +593 -0
- package/src/errors.ts +40 -0
- package/src/index.ts +4 -31
- package/src/services/packager.ts +177 -0
- package/src/services/publisher.ts +237 -0
- package/src/services/upload.ts +52 -0
- package/src/services/version-manager.ts +65 -0
- package/src/types.ts +396 -0
- package/src/utils.ts +128 -0
- package/src/validators/plugin-validator.ts +635 -0
- package/src/commands/version.ts +0 -20
- package/src/db/client.ts +0 -180
- package/src/services/api.ts +0 -128
- package/src/services/auth.ts +0 -46
- package/src/services/cache.ts +0 -101
- package/src/services/download.ts +0 -148
- package/src/services/link.ts +0 -86
- package/src/services/project.ts +0 -179
- package/src/types/api.ts +0 -115
- package/src/types/db.ts +0 -31
- package/src/utils/errors.ts +0 -40
- package/src/utils/platform.ts +0 -6
- package/src/utils/target.ts +0 -114
package/src/db/client.ts
DELETED
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
import { createClient as createLibsqlClient } from '@libsql/client';
|
|
2
|
-
import { Pool } from 'pg';
|
|
3
|
-
import { getDbPath } from '../config';
|
|
4
|
-
|
|
5
|
-
type Query = string | { sql: string; args?: unknown[] };
|
|
6
|
-
|
|
7
|
-
export interface DbClient {
|
|
8
|
-
execute(query: Query): Promise<{ rows: unknown[] }>;
|
|
9
|
-
close(): Promise<void> | void;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
let client: DbClient | null = null;
|
|
13
|
-
|
|
14
|
-
function normalizeQuery(query: Query): { sql: string; args: unknown[] } {
|
|
15
|
-
if (typeof query === 'string') {
|
|
16
|
-
return { sql: query, args: [] };
|
|
17
|
-
}
|
|
18
|
-
return {
|
|
19
|
-
sql: query.sql,
|
|
20
|
-
args: query.args || [],
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function convertParamsToPg(sql: string): string {
|
|
25
|
-
let paramIndex = 0;
|
|
26
|
-
return sql.replace(/\?/g, () => {
|
|
27
|
-
paramIndex += 1;
|
|
28
|
-
return `$${paramIndex}`;
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function createSqliteClient(): DbClient {
|
|
33
|
-
const sqliteClient = createLibsqlClient({
|
|
34
|
-
url: `file:${getDbPath()}`,
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
return {
|
|
38
|
-
execute: (query) => sqliteClient.execute(query as any),
|
|
39
|
-
close: () => sqliteClient.close(),
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function createPostgresClient(): DbClient {
|
|
44
|
-
const connectionString =
|
|
45
|
-
process.env.CLI_DATABASE_URL ||
|
|
46
|
-
process.env.LOCAL_DATABASE_URL ||
|
|
47
|
-
process.env.DATABASE_URL ||
|
|
48
|
-
process.env.POSTGRES_URL;
|
|
49
|
-
|
|
50
|
-
if (!connectionString) {
|
|
51
|
-
throw new Error(
|
|
52
|
-
'Postgres connection string not provided. Set CLI_DATABASE_URL, LOCAL_DATABASE_URL, or DATABASE_URL.'
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const pool = new Pool({ connectionString });
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
async execute(query) {
|
|
60
|
-
const { sql, args } = normalizeQuery(query);
|
|
61
|
-
const text = convertParamsToPg(sql);
|
|
62
|
-
const res = await pool.query(text, args);
|
|
63
|
-
return { rows: res.rows };
|
|
64
|
-
},
|
|
65
|
-
async close() {
|
|
66
|
-
await pool.end();
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function createDbClient(): DbClient {
|
|
72
|
-
const driver = (process.env.CLI_DB_DRIVER || process.env.DB_DRIVER || 'sqlite').toLowerCase();
|
|
73
|
-
if (driver === 'postgres' || driver === 'postgresql' || driver === 'pg') {
|
|
74
|
-
return createPostgresClient();
|
|
75
|
-
}
|
|
76
|
-
return createSqliteClient();
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function getDb(): DbClient {
|
|
80
|
-
if (!client) {
|
|
81
|
-
client = createDbClient();
|
|
82
|
-
}
|
|
83
|
-
return client;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Future extension: Support remote sync (Turso); fallback to current driver for Postgres
|
|
87
|
-
export function getDbWithSync(authToken: string): DbClient {
|
|
88
|
-
const driver = (process.env.CLI_DB_DRIVER || process.env.DB_DRIVER || 'sqlite').toLowerCase();
|
|
89
|
-
|
|
90
|
-
if (driver === 'postgres' || driver === 'postgresql' || driver === 'pg') {
|
|
91
|
-
return getDb();
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const sqliteClient = createLibsqlClient({
|
|
95
|
-
url: `file:${getDbPath()}`,
|
|
96
|
-
syncUrl: process.env.TURSO_DATABASE_URL,
|
|
97
|
-
authToken,
|
|
98
|
-
syncInterval: 60, // Sync every 60 seconds
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
execute: (query) => sqliteClient.execute(query as any),
|
|
103
|
-
close: () => sqliteClient.close(),
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export async function initDb(): Promise<void> {
|
|
108
|
-
const db = getDb();
|
|
109
|
-
|
|
110
|
-
// Create projects table
|
|
111
|
-
await db.execute(`
|
|
112
|
-
CREATE TABLE IF NOT EXISTS projects (
|
|
113
|
-
id TEXT PRIMARY KEY,
|
|
114
|
-
path TEXT NOT NULL UNIQUE,
|
|
115
|
-
name TEXT,
|
|
116
|
-
registered_at TEXT NOT NULL,
|
|
117
|
-
last_used_at TEXT NOT NULL
|
|
118
|
-
)
|
|
119
|
-
`);
|
|
120
|
-
|
|
121
|
-
await db.execute(`
|
|
122
|
-
CREATE INDEX IF NOT EXISTS idx_projects_path ON projects(path)
|
|
123
|
-
`);
|
|
124
|
-
|
|
125
|
-
// Create plugin_cache table
|
|
126
|
-
await db.execute(`
|
|
127
|
-
CREATE TABLE IF NOT EXISTS plugin_cache (
|
|
128
|
-
id TEXT PRIMARY KEY,
|
|
129
|
-
full_name TEXT NOT NULL,
|
|
130
|
-
type TEXT NOT NULL,
|
|
131
|
-
version TEXT NOT NULL,
|
|
132
|
-
cache_path TEXT NOT NULL,
|
|
133
|
-
checksum TEXT NOT NULL,
|
|
134
|
-
size_bytes INTEGER,
|
|
135
|
-
downloaded_at TEXT NOT NULL,
|
|
136
|
-
UNIQUE(full_name, version)
|
|
137
|
-
)
|
|
138
|
-
`);
|
|
139
|
-
|
|
140
|
-
await db.execute(`
|
|
141
|
-
CREATE INDEX IF NOT EXISTS idx_cache_name ON plugin_cache(full_name)
|
|
142
|
-
`);
|
|
143
|
-
|
|
144
|
-
// Create project_plugins table
|
|
145
|
-
await db.execute(`
|
|
146
|
-
CREATE TABLE IF NOT EXISTS project_plugins (
|
|
147
|
-
id TEXT PRIMARY KEY,
|
|
148
|
-
project_id TEXT NOT NULL,
|
|
149
|
-
full_name TEXT NOT NULL,
|
|
150
|
-
type TEXT NOT NULL,
|
|
151
|
-
version TEXT NOT NULL,
|
|
152
|
-
cache_path TEXT NOT NULL,
|
|
153
|
-
link_path TEXT NOT NULL,
|
|
154
|
-
installed_at TEXT NOT NULL,
|
|
155
|
-
source TEXT DEFAULT 'direct',
|
|
156
|
-
source_list TEXT,
|
|
157
|
-
UNIQUE(project_id, full_name),
|
|
158
|
-
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
|
159
|
-
)
|
|
160
|
-
`);
|
|
161
|
-
|
|
162
|
-
await db.execute(`
|
|
163
|
-
CREATE INDEX IF NOT EXISTS idx_pp_project ON project_plugins(project_id)
|
|
164
|
-
`);
|
|
165
|
-
|
|
166
|
-
// Create config table
|
|
167
|
-
await db.execute(`
|
|
168
|
-
CREATE TABLE IF NOT EXISTS config (
|
|
169
|
-
key TEXT PRIMARY KEY,
|
|
170
|
-
value TEXT NOT NULL
|
|
171
|
-
)
|
|
172
|
-
`);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export async function closeDb(): Promise<void> {
|
|
176
|
-
if (client) {
|
|
177
|
-
await client.close();
|
|
178
|
-
client = null;
|
|
179
|
-
}
|
|
180
|
-
}
|
package/src/services/api.ts
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { loadSecrets } from './auth';
|
|
2
|
-
import { config } from '../config';
|
|
3
|
-
import type {
|
|
4
|
-
AuthStartResponse,
|
|
5
|
-
AuthPollResponse,
|
|
6
|
-
UserProfile,
|
|
7
|
-
CapabilityDownload,
|
|
8
|
-
PluginDetail,
|
|
9
|
-
ListDownload,
|
|
10
|
-
SearchResult,
|
|
11
|
-
SearchOptions,
|
|
12
|
-
} from '../types/api';
|
|
13
|
-
|
|
14
|
-
class ApiClient {
|
|
15
|
-
private accessToken: string | null = null;
|
|
16
|
-
private initialized = false;
|
|
17
|
-
|
|
18
|
-
async init(): Promise<void> {
|
|
19
|
-
if (this.initialized) return;
|
|
20
|
-
const secrets = await loadSecrets();
|
|
21
|
-
this.accessToken = secrets?.access_token || null;
|
|
22
|
-
this.initialized = true;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
isAuthenticated(): boolean {
|
|
26
|
-
return !!this.accessToken;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
private async request<T>(
|
|
30
|
-
method: string,
|
|
31
|
-
path: string,
|
|
32
|
-
options: {
|
|
33
|
-
body?: unknown;
|
|
34
|
-
auth?: boolean;
|
|
35
|
-
} = {}
|
|
36
|
-
): Promise<T> {
|
|
37
|
-
if (!this.initialized) {
|
|
38
|
-
await this.init();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const headers: Record<string, string> = {
|
|
42
|
-
'Content-Type': 'application/json',
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
if (options.auth !== false && this.accessToken) {
|
|
46
|
-
headers['Authorization'] = `Bearer ${this.accessToken}`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const response = await fetch(`${config.apiBase}${path}`, {
|
|
50
|
-
method,
|
|
51
|
-
headers,
|
|
52
|
-
body: method === 'POST' || method === 'PUT' || method === 'PATCH'
|
|
53
|
-
? JSON.stringify(options.body ?? {})
|
|
54
|
-
: undefined,
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
const data = await response.json() as { data?: T; error?: { message: string } };
|
|
58
|
-
|
|
59
|
-
if (!response.ok) {
|
|
60
|
-
throw new Error(data.error?.message || 'API request failed');
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return data.data as T;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Auth
|
|
67
|
-
async startAuth(): Promise<AuthStartResponse> {
|
|
68
|
-
return this.request('POST', '/v1/auth/cli/start', { auth: false });
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async pollAuth(code: string): Promise<AuthPollResponse> {
|
|
72
|
-
return this.request('GET', `/v1/auth/cli/poll?code=${code}`, { auth: false });
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async getMe(): Promise<UserProfile> {
|
|
76
|
-
return this.request('GET', '/v1/auth/me');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Capabilities
|
|
80
|
-
async getCapabilityDownload(fullName: string): Promise<CapabilityDownload> {
|
|
81
|
-
const encoded = encodeURIComponent(fullName);
|
|
82
|
-
return this.request('GET', `/v1/capabilities/by-name/download?full_name=${encoded}`);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async getCapabilityDownloadBySlug(author: string, slug: string): Promise<CapabilityDownload> {
|
|
86
|
-
return this.request('GET', `/v1/capabilities/${author}/${slug}/download`);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Plugins
|
|
90
|
-
async getPluginByName(fullName: string): Promise<PluginDetail> {
|
|
91
|
-
const encoded = encodeURIComponent(fullName);
|
|
92
|
-
return this.request('GET', `/v1/plugins/by-name?full_name=${encoded}`);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async getPluginDownload(user: string, plugin: string, version?: string): Promise<CapabilityDownload> {
|
|
96
|
-
let path = `/v1/plugins/${user}/${plugin}/download`;
|
|
97
|
-
if (version) {
|
|
98
|
-
path += `?version=${version}`;
|
|
99
|
-
}
|
|
100
|
-
return this.request('GET', path);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Lists
|
|
104
|
-
async getListDownload(username: string, slugOrId: string): Promise<ListDownload> {
|
|
105
|
-
return this.request('GET', `/v1/lists/${username}/list/${slugOrId}/download`);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Search
|
|
109
|
-
async search(query: string, options?: SearchOptions): Promise<SearchResult> {
|
|
110
|
-
const params = new URLSearchParams({ q: query });
|
|
111
|
-
if (options?.type) params.set('type', options.type);
|
|
112
|
-
if (options?.per_page) params.set('per_page', String(options.per_page));
|
|
113
|
-
return this.request('GET', `/v1/search?${params}`);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Reset token (for logout)
|
|
117
|
-
resetToken(): void {
|
|
118
|
-
this.accessToken = null;
|
|
119
|
-
this.initialized = false;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Set token (for login)
|
|
123
|
-
setToken(token: string): void {
|
|
124
|
-
this.accessToken = token;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export const api = new ApiClient();
|
package/src/services/auth.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import fs from 'fs/promises';
|
|
2
|
-
import { getSecretsPath } from '../config';
|
|
3
|
-
|
|
4
|
-
const SECRETS_FILE = 'secrets.json';
|
|
5
|
-
|
|
6
|
-
export interface Secrets {
|
|
7
|
-
access_token: string;
|
|
8
|
-
refresh_token: string;
|
|
9
|
-
created_at: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export async function saveSecrets(secrets: Secrets): Promise<void> {
|
|
13
|
-
const filePath = getSecretsPath();
|
|
14
|
-
|
|
15
|
-
await fs.writeFile(
|
|
16
|
-
filePath,
|
|
17
|
-
JSON.stringify(secrets, null, 2),
|
|
18
|
-
{ mode: 0o600 } // Only current user can read/write
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export async function loadSecrets(): Promise<Secrets | null> {
|
|
23
|
-
const filePath = getSecretsPath();
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
const content = await fs.readFile(filePath, 'utf-8');
|
|
27
|
-
return JSON.parse(content);
|
|
28
|
-
} catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function clearSecrets(): Promise<void> {
|
|
34
|
-
const filePath = getSecretsPath();
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
await fs.rm(filePath);
|
|
38
|
-
} catch {
|
|
39
|
-
// File doesn't exist, ignore
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export async function getAccessToken(): Promise<string | null> {
|
|
44
|
-
const secrets = await loadSecrets();
|
|
45
|
-
return secrets?.access_token || null;
|
|
46
|
-
}
|
package/src/services/cache.ts
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import fs from 'fs/promises';
|
|
2
|
-
import { nanoid } from 'nanoid';
|
|
3
|
-
import { getDb } from '../db/client';
|
|
4
|
-
import type { CacheEntry } from '../types/db';
|
|
5
|
-
|
|
6
|
-
export async function getCachedPlugin(fullName: string, version: string): Promise<CacheEntry | null> {
|
|
7
|
-
const db = getDb();
|
|
8
|
-
const result = await db.execute({
|
|
9
|
-
sql: 'SELECT * FROM plugin_cache WHERE full_name = ? AND version = ?',
|
|
10
|
-
args: [fullName, version],
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
if (result.rows.length === 0) return null;
|
|
14
|
-
return result.rows[0] as unknown as CacheEntry;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export async function cachePlugin(entry: {
|
|
18
|
-
full_name: string;
|
|
19
|
-
type: string;
|
|
20
|
-
version: string;
|
|
21
|
-
cache_path: string;
|
|
22
|
-
checksum: string;
|
|
23
|
-
}): Promise<void> {
|
|
24
|
-
const db = getDb();
|
|
25
|
-
const id = nanoid();
|
|
26
|
-
const now = new Date().toISOString();
|
|
27
|
-
|
|
28
|
-
// Calculate directory size
|
|
29
|
-
let sizeBytes: number | null = null;
|
|
30
|
-
try {
|
|
31
|
-
sizeBytes = await getDirSize(entry.cache_path);
|
|
32
|
-
} catch {
|
|
33
|
-
// Ignore
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
await db.execute({
|
|
37
|
-
sql: `INSERT INTO plugin_cache
|
|
38
|
-
(id, full_name, type, version, cache_path, checksum, size_bytes, downloaded_at)
|
|
39
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
40
|
-
ON CONFLICT(full_name, version) DO UPDATE SET
|
|
41
|
-
id = excluded.id,
|
|
42
|
-
type = excluded.type,
|
|
43
|
-
cache_path = excluded.cache_path,
|
|
44
|
-
checksum = excluded.checksum,
|
|
45
|
-
size_bytes = excluded.size_bytes,
|
|
46
|
-
downloaded_at = excluded.downloaded_at`,
|
|
47
|
-
args: [
|
|
48
|
-
id,
|
|
49
|
-
entry.full_name,
|
|
50
|
-
entry.type,
|
|
51
|
-
entry.version,
|
|
52
|
-
entry.cache_path,
|
|
53
|
-
entry.checksum,
|
|
54
|
-
sizeBytes,
|
|
55
|
-
now,
|
|
56
|
-
],
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function removeFromCache(fullName: string, version: string): Promise<void> {
|
|
61
|
-
const entry = await getCachedPlugin(fullName, version);
|
|
62
|
-
|
|
63
|
-
if (entry) {
|
|
64
|
-
// Delete files
|
|
65
|
-
try {
|
|
66
|
-
await fs.rm(entry.cache_path, { recursive: true, force: true });
|
|
67
|
-
} catch {
|
|
68
|
-
// Ignore
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Delete record
|
|
72
|
-
const db = getDb();
|
|
73
|
-
await db.execute({
|
|
74
|
-
sql: 'DELETE FROM plugin_cache WHERE id = ?',
|
|
75
|
-
args: [entry.id],
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export async function getAllCachedPlugins(): Promise<CacheEntry[]> {
|
|
81
|
-
const db = getDb();
|
|
82
|
-
const result = await db.execute('SELECT * FROM plugin_cache ORDER BY downloaded_at DESC');
|
|
83
|
-
return result.rows as unknown as CacheEntry[];
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function getDirSize(dirPath: string): Promise<number> {
|
|
87
|
-
let size = 0;
|
|
88
|
-
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
89
|
-
|
|
90
|
-
for (const entry of entries) {
|
|
91
|
-
const fullPath = `${dirPath}/${entry.name}`;
|
|
92
|
-
if (entry.isDirectory()) {
|
|
93
|
-
size += await getDirSize(fullPath);
|
|
94
|
-
} else {
|
|
95
|
-
const stats = await fs.stat(fullPath);
|
|
96
|
-
size += stats.size;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return size;
|
|
101
|
-
}
|
package/src/services/download.ts
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
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
|
-
}
|
package/src/services/link.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
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
|
-
}
|