Skip to content

Commit b8286a1

Browse files
committed
feat: Refactored plugin resolution and added an ApiClient for network calls
1 parent 0389b5e commit b8286a1

5 files changed

Lines changed: 145 additions & 62 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"parse-json": "^8.1.0",
2323
"react": "^18.3.1",
2424
"semver": "^7.5.4",
25+
"latest-semver": "^4.0.0",
2526
"supports-color": "^9.4.0",
2627
"nanoid": "^5.0.9"
2728
},

src/api/index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import * as fsSync from 'node:fs';
2+
import * as fs from 'node:fs/promises';
3+
import path from 'node:path';
4+
import { Readable } from 'node:stream';
5+
import { finished } from 'node:stream/promises';
6+
7+
import { PluginSearchQuery, PluginSearchResult } from './types.js';
8+
9+
const API_BASE_URL = 'https://api.codifycli.com'
10+
11+
export const ApiClient = {
12+
async searchPlugins(query: PluginSearchQuery[]): Promise<PluginSearchResult> {
13+
const body = JSON.stringify({ query });
14+
const res = await fetch(
15+
`${API_BASE_URL}/v1/plugins/versions/search`,
16+
{ method: 'POST', body, headers: { 'Content-Type': 'application/json' } }
17+
);
18+
19+
const json = await res.json();
20+
if (!res.ok) {
21+
throw new Error(JSON.stringify(json, null, 2));
22+
}
23+
24+
return json.results as unknown as PluginSearchResult;
25+
},
26+
27+
async downloadPlugin(filePath: string, url: string): Promise<void> {
28+
const { body } = await fetch(url)
29+
30+
const dirname = path.dirname(filePath);
31+
if (!await fs.stat(dirname).then((s) => s.isDirectory()).catch(() => false)) {
32+
await fs.mkdir(dirname, { recursive: true });
33+
}
34+
35+
const ws = fsSync.createWriteStream(filePath)
36+
// Different type definitions here for readable stream (NodeJS vs DOM). Small hack to fix that
37+
await finished(Readable.fromWeb(body as never).pipe(ws));
38+
},
39+
};

src/api/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export interface PluginSearchQuery {
2+
name: string;
3+
version?: string;
4+
}
5+
6+
export interface PluginSearchResult {
7+
[x: string]: PluginInfo;
8+
}
9+
10+
export interface PluginInfo {
11+
version: string;
12+
isLatest: boolean;
13+
isBeta: boolean;
14+
downloadLink: string;
15+
}

src/plugins/plugin-manager.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,7 @@ export class PluginManager {
130130
...project?.projectConfig?.plugins,
131131
};
132132

133-
const configPlugins = await Promise.all(Object.entries(pluginDefinitions).map(([name, version]) =>
134-
PluginResolver.resolve(name, version)
135-
));
136-
137-
const existingPlugins = await PluginResolver.resolveExisting(Object.keys(pluginDefinitions));
138-
139-
return [...configPlugins, ...existingPlugins.filter((p) => !configPlugins.some((p2) => p2.name === p.name))];
133+
return PluginResolver.resolveAll(pluginDefinitions);
140134
}
141135

142136
private async initializePlugins(plugins: Plugin[], secureMode: boolean): Promise<Map<string, string[]>> {

src/plugins/resolver.ts

Lines changed: 89 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,109 @@
1+
import latestSemver from 'latest-semver';
12
import * as fsSync from 'node:fs';
23
import * as fs from 'node:fs/promises';
34
import * as os from 'node:os';
45
import path from 'node:path';
5-
import { Readable } from 'node:stream';
6-
import { finished } from 'node:stream/promises';
76

7+
import { ApiClient } from '../api/index.js';
8+
import { PluginInfo } from '../api/types.js';
89
import { ctx } from '../events/context.js';
910
import { Plugin } from './plugin.js';
1011

11-
const DEFAULT_PLUGIN_URL = 'https://codify-plugin-library.s3.amazonaws.com/codify-core/index.js';
1212
const PLUGIN_CACHE_DIR = path.resolve(os.homedir(), '.codify/plugins')
1313

1414
export class PluginResolver {
1515

16-
static async resolve(name: string, version: string): Promise<Plugin> {
16+
static async resolveAll(definitions: Record<string, string>): Promise<Plugin[]> {
1717
await PluginResolver.checkAndCreateCacheDirIfNotExists()
1818

19-
let directoryStat;
20-
try {
21-
directoryStat = await fs.stat(version);
22-
} catch {
23-
}
19+
const localPluginDefs = Object.entries(definitions)
20+
.filter(([k, v]) => v.endsWith('.js') || v.endsWith('.ts'))
21+
const localPlugins = await Promise.all(localPluginDefs.map(([name, path]) =>
22+
PluginResolver.resolveLocalPlugin(name, path)
23+
))
2424

25-
// For easier development. A direct js file can be specified for the plugin.
26-
if (directoryStat && directoryStat.isFile()) {
27-
return PluginResolver.resolvePluginFs(name, version)
28-
}
25+
const networkPluginDefs = Object.entries(definitions)
26+
.filter(([k]) => !localPluginDefs.some(([lk]) => k === lk))
2927

30-
if (name === 'default') {
31-
return PluginResolver.resolvePluginDefault(name, version)
28+
if (networkPluginDefs.length === 0) {
29+
return localPlugins;
3230
}
3331

34-
throw new Error('Non-default plugins are not currently supported');
35-
}
36-
37-
static async resolveExisting(exclude: string[]): Promise<Plugin[]> {
38-
let files;
39-
try {
40-
files = await fs.readdir(PLUGIN_CACHE_DIR);
41-
} catch {
42-
}
32+
// Fetch the latest plugin info from the server
33+
const latestPluginInfo = await ApiClient
34+
.searchPlugins(networkPluginDefs.map(([name, version]) => ({ name, version })))
35+
.catch((e: Error) => {
36+
console.warn('Unable to fetch latest plugin info');
37+
ctx.debug(`Unable to fetch latest plugin info:\n${e.message}`);
38+
}) ?? undefined;
4339

44-
if (!files) {
45-
return [];
46-
}
40+
const networkPlugins = await Promise.all(networkPluginDefs.map(([name, version]) =>
41+
PluginResolver.resolvePluginNetwork(name, version, latestPluginInfo?.[name])
42+
))
4743

48-
return files
49-
.filter((f) => f.endsWith('.js'))
50-
.filter((f) => !exclude.includes(getPluginName(f)))
51-
.map((f) => {
52-
const name = getPluginName(f);
53-
const p = path.join(PLUGIN_CACHE_DIR, f);
54-
55-
return new Plugin(name, '', p);
56-
})
57-
58-
function getPluginName(fileName: string) {
59-
return fileName.split('.')[0];
60-
}
44+
return [...networkPlugins, ...localPlugins];
6145
}
6246

63-
private static async resolvePluginFs(name: string, filePath: string): Promise<Plugin> {
47+
static async resolveLocalPlugin(name: string, filePath: string, version?: string): Promise<Plugin> {
6448
const fileExtension = filePath.slice(filePath.lastIndexOf('.'))
6549
if (fileExtension !== '.js' && fileExtension !== '.ts') {
6650
throw new Error(`Only .js and .ts plugins are support currently. Can't resolve ${filePath}`);
6751
}
6852

53+
let stats: fsSync.Stats;
54+
try {
55+
stats = await fs.stat(filePath);
56+
} catch (e) {
57+
throw new Error(`Unable to find plugin file path ${filePath}`)
58+
}
59+
60+
if (!stats.isFile()) {
61+
throw new Error(`Provided plugin path ${filePath} does not reference a file`);
62+
}
63+
6964
return new Plugin(
7065
name,
71-
'0.0.0',
66+
version ?? '0.0.0',
7267
filePath,
7368
)
7469
}
7570

76-
private static async resolvePluginDefault(name: string, version: string): Promise<Plugin> {
77-
const { body } = await fetch(DEFAULT_PLUGIN_URL)
78-
if (!body) {
79-
throw new Error('Un-able to fetch the default plugin (body not found). Exiting');
71+
private static async resolvePluginNetwork(name: string, version: string, latestInfoFromNetwork?: PluginInfo): Promise<Plugin> {
72+
const resolvedVersion = (version === 'latest') ? await PluginResolver.resolveLatestLocalVersion(name) : version;
73+
if (!resolvedVersion && !latestInfoFromNetwork) {
74+
throw new Error(`Plugin ${name} not found and not able to download from registry. Please try again at a later time`);
8075
}
8176

82-
const filePath = path.join(PLUGIN_CACHE_DIR, 'default.js');
83-
const ws = fsSync.createWriteStream(filePath)
77+
if (!resolvedVersion) {
78+
return downloadFreshPlugin();
79+
}
8480

85-
// Different type definitions here for readable stream (NodeJS vs DOM). Small hack to fix that
86-
await finished(Readable.fromWeb(body as never).pipe(ws));
81+
const localPluginExists = await PluginResolver.localPluginExists(name, resolvedVersion);
82+
if (!localPluginExists && !latestInfoFromNetwork) {
83+
throw new Error(`Plugin ${name} not found and not able to download from registry. Please try again at a later time`);
84+
}
8785

88-
return new Plugin(
89-
name,
90-
version,
91-
filePath,
92-
)
86+
// Plugin already exists, then no need to download. OR couldn't fetch plugin info from online then just resolve local version. OR we already have the latest version
87+
if ((version !== 'latest' && localPluginExists)
88+
|| (localPluginExists && !latestInfoFromNetwork)
89+
|| (resolvedVersion === latestInfoFromNetwork!.version)
90+
) {
91+
return PluginResolver.resolveLocalPlugin(name, `${PLUGIN_CACHE_DIR}/${name}/${version}/index.js`);
92+
}
93+
94+
return downloadFreshPlugin();
95+
96+
// Set up folders and download plugin from the network.
97+
async function downloadFreshPlugin(): Promise<Plugin> {
98+
const filePath = `${PLUGIN_CACHE_DIR}/${name}/${version}/index.js`;
99+
await ApiClient.downloadPlugin(filePath, latestInfoFromNetwork!.downloadLink);
100+
101+
return new Plugin(
102+
name,
103+
version,
104+
filePath,
105+
)
106+
}
93107
}
94108

95109
private static async checkAndCreateCacheDirIfNotExists() {
@@ -111,4 +125,24 @@ export class PluginResolver {
111125
ctx.log('Creating a new cache dir for codify');
112126
await fs.mkdir(PLUGIN_CACHE_DIR, { recursive: true });
113127
}
128+
129+
private static async resolveLatestLocalVersion(name: string): Promise<string | undefined> {
130+
try {
131+
const pluginPath = path.join(PLUGIN_CACHE_DIR, name);
132+
const versions = await fs.readdir(pluginPath);
133+
return latestSemver(versions);
134+
} catch (e) {
135+
return undefined;
136+
}
137+
}
138+
139+
private static async localPluginExists(name: string, version: string): Promise<boolean> {
140+
const pluginPath = path.join(PLUGIN_CACHE_DIR, name, version, 'index.js');
141+
try {
142+
const fileStats = await fs.stat(pluginPath)
143+
return fileStats.isFile();
144+
} catch (e) {
145+
return false;
146+
}
147+
}
114148
}

0 commit comments

Comments
 (0)