@checkstack/backend 0.8.2 → 0.9.1
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/CHANGELOG.md +333 -0
- package/drizzle/0001_slim_mordo.sql +34 -0
- package/drizzle/meta/0001_snapshot.json +444 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +18 -13
- package/src/index.ts +276 -17
- package/src/plugin-deregistration.test.ts +137 -0
- package/src/plugin-manager/api-router.ts +35 -11
- package/src/plugin-manager/plugin-loader.ts +73 -0
- package/src/plugin-manager.ts +295 -105
- package/src/schema.ts +79 -1
- package/src/services/cache-manager.test.ts +172 -0
- package/src/services/cache-manager.ts +67 -14
- package/src/services/compatibility-checker.test.ts +146 -0
- package/src/services/compatibility-checker.ts +137 -0
- package/src/services/dev-auth.test.ts +87 -0
- package/src/services/dev-auth.ts +56 -0
- package/src/services/event-bus.test.ts +52 -0
- package/src/services/event-bus.ts +27 -1
- package/src/services/plugin-artifact-store.ts +131 -0
- package/src/services/plugin-bundle-resolver.ts +76 -0
- package/src/services/plugin-event-recorder.ts +87 -0
- package/src/services/plugin-installers/catalog-installer.ts +33 -0
- package/src/services/plugin-installers/github-installer.ts +207 -0
- package/src/services/plugin-installers/install-from-tarball.ts +69 -0
- package/src/services/plugin-installers/installer-registry.ts +51 -0
- package/src/services/plugin-installers/npm-installer.ts +156 -0
- package/src/services/plugin-installers/plugin-install-error.ts +37 -0
- package/src/services/plugin-installers/tarball-installer.ts +80 -0
- package/src/services/plugin-installers/tarball-utils.test.ts +200 -0
- package/src/services/plugin-installers/tarball-utils.ts +172 -0
- package/src/services/plugin-manager-orchestrator.ts +522 -0
- package/src/services/plugin-manager-router.ts +219 -0
- package/src/services/queue-manager.ts +77 -2
- package/src/services/queue-proxy.ts +7 -0
- package/src/utils/plugin-discovery.test.ts +6 -0
- package/src/utils/plugin-discovery.ts +6 -1
- package/tsconfig.json +3 -0
- package/src/plugin-lifecycle.test.ts +0 -276
- package/src/plugin-manager/plugin-admin-router.ts +0 -89
- package/src/services/plugin-installer.test.ts +0 -90
- package/src/services/plugin-installer.ts +0 -70
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { implement, ORPCError } from "@orpc/server";
|
|
2
|
+
import { desc } from "drizzle-orm";
|
|
3
|
+
import {
|
|
4
|
+
autoAuthMiddleware,
|
|
5
|
+
coreServices,
|
|
6
|
+
type RpcContext,
|
|
7
|
+
type SafeDatabase,
|
|
8
|
+
} from "@checkstack/backend-api";
|
|
9
|
+
import {
|
|
10
|
+
pluginManagerContract,
|
|
11
|
+
type InstalledPlugin,
|
|
12
|
+
} from "@checkstack/pluginmanager-common";
|
|
13
|
+
import {
|
|
14
|
+
pluginPackageTypeSchema,
|
|
15
|
+
pluginSourceSchema,
|
|
16
|
+
} from "@checkstack/common";
|
|
17
|
+
import type { PluginManager } from "../plugin-manager";
|
|
18
|
+
import type { ServiceRegistry } from "./service-registry";
|
|
19
|
+
import type { PluginEventRecorder } from "./plugin-event-recorder";
|
|
20
|
+
import { plugins } from "../schema";
|
|
21
|
+
import {
|
|
22
|
+
installOriginator,
|
|
23
|
+
previewInstallOriginator,
|
|
24
|
+
previewUninstallOriginator,
|
|
25
|
+
uninstallOriginator,
|
|
26
|
+
} from "./plugin-manager-orchestrator";
|
|
27
|
+
import {
|
|
28
|
+
PluginInstallError,
|
|
29
|
+
type PluginInstallErrorCode,
|
|
30
|
+
} from "./plugin-installers/plugin-install-error";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Map our installer/orchestrator error codes onto the closest oRPC error
|
|
34
|
+
* codes so the UI sees a meaningful HTTP status + message instead of a
|
|
35
|
+
* generic `INTERNAL_SERVER_ERROR`.
|
|
36
|
+
*
|
|
37
|
+
* oRPC's built-in `ORPCError` codes match common HTTP semantics; we route
|
|
38
|
+
* `COMPATIBILITY_FAILED` and `VALIDATION_FAILED` to `BAD_REQUEST` because
|
|
39
|
+
* neither is a server fault — both indicate user-correctable input.
|
|
40
|
+
*/
|
|
41
|
+
const ORPC_CODE_MAP: Record<
|
|
42
|
+
PluginInstallErrorCode,
|
|
43
|
+
| "BAD_REQUEST"
|
|
44
|
+
| "UNAUTHORIZED"
|
|
45
|
+
| "FORBIDDEN"
|
|
46
|
+
| "NOT_FOUND"
|
|
47
|
+
| "PAYLOAD_TOO_LARGE"
|
|
48
|
+
| "BAD_GATEWAY"
|
|
49
|
+
| "NOT_IMPLEMENTED"
|
|
50
|
+
> = {
|
|
51
|
+
NOT_FOUND: "NOT_FOUND",
|
|
52
|
+
BAD_REQUEST: "BAD_REQUEST",
|
|
53
|
+
UNAUTHORIZED: "UNAUTHORIZED",
|
|
54
|
+
FORBIDDEN: "FORBIDDEN",
|
|
55
|
+
PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
|
|
56
|
+
BAD_GATEWAY: "BAD_GATEWAY",
|
|
57
|
+
VALIDATION_FAILED: "BAD_REQUEST",
|
|
58
|
+
COMPATIBILITY_FAILED: "BAD_REQUEST",
|
|
59
|
+
NOT_IMPLEMENTED: "NOT_IMPLEMENTED",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run an originator function and translate any `PluginInstallError` into
|
|
64
|
+
* an `ORPCError` with the right code + the user-facing message intact.
|
|
65
|
+
* Other errors propagate untouched and surface as 500s — those represent
|
|
66
|
+
* genuine bugs and stay loud.
|
|
67
|
+
*/
|
|
68
|
+
async function withTranslatedErrors<T>(fn: () => Promise<T>): Promise<T> {
|
|
69
|
+
try {
|
|
70
|
+
return await fn();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error instanceof PluginInstallError) {
|
|
73
|
+
const data: Record<string, unknown> = { kind: error.code };
|
|
74
|
+
if (error.details) Object.assign(data, error.details);
|
|
75
|
+
throw new ORPCError(ORPC_CODE_MAP[error.code], {
|
|
76
|
+
message: error.message,
|
|
77
|
+
data,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* oRPC router that backs `pluginManagerContract`.
|
|
86
|
+
*
|
|
87
|
+
* Lives in `core/backend` because it directly references the in-process
|
|
88
|
+
* `PluginManager` instance. Wired into the request pipeline via
|
|
89
|
+
* `pluginManager.registerCoreRouter("pluginmanager", ...)`.
|
|
90
|
+
*/
|
|
91
|
+
export function createPluginManagerRouter({
|
|
92
|
+
db,
|
|
93
|
+
pluginManager,
|
|
94
|
+
registry,
|
|
95
|
+
eventRecorder,
|
|
96
|
+
workspaceRoot,
|
|
97
|
+
runtimeDir,
|
|
98
|
+
}: {
|
|
99
|
+
db: SafeDatabase<Record<string, unknown>>;
|
|
100
|
+
pluginManager: PluginManager;
|
|
101
|
+
registry: ServiceRegistry;
|
|
102
|
+
eventRecorder: PluginEventRecorder;
|
|
103
|
+
workspaceRoot: string;
|
|
104
|
+
runtimeDir: string;
|
|
105
|
+
}) {
|
|
106
|
+
// touch coreServices to keep the import alive in case future router
|
|
107
|
+
// procedures need to resolve services on demand.
|
|
108
|
+
void coreServices;
|
|
109
|
+
|
|
110
|
+
const impl = implement(pluginManagerContract)
|
|
111
|
+
.$context<RpcContext>()
|
|
112
|
+
.use(autoAuthMiddleware);
|
|
113
|
+
|
|
114
|
+
return impl.router({
|
|
115
|
+
list: impl.list.handler(async () => {
|
|
116
|
+
const rows = await db
|
|
117
|
+
.select()
|
|
118
|
+
.from(plugins)
|
|
119
|
+
.orderBy(desc(plugins.id));
|
|
120
|
+
const list: InstalledPlugin[] = rows.map((row) => {
|
|
121
|
+
// type may legitimately not match the enum for legacy/tooling rows
|
|
122
|
+
// — fall back to a safe value rather than 500-ing on parse.
|
|
123
|
+
const typeParsed = pluginPackageTypeSchema.safeParse(row.type);
|
|
124
|
+
// metadata is JSONB; drizzle returns `unknown | null`. The contract
|
|
125
|
+
// declares it as `optional()` (accepts undefined, NOT null), so
|
|
126
|
+
// coerce. Also accept only objects — string/number/array would fail
|
|
127
|
+
// schema validation.
|
|
128
|
+
const metadata =
|
|
129
|
+
row.metadata && typeof row.metadata === "object" && !Array.isArray(row.metadata)
|
|
130
|
+
? (row.metadata as InstalledPlugin["metadata"])
|
|
131
|
+
: undefined;
|
|
132
|
+
// source: null for monorepo-local; validated PluginSource for
|
|
133
|
+
// runtime-installed. Wrap parse in safeParse so a malformed
|
|
134
|
+
// legacy row doesn't break the whole list response.
|
|
135
|
+
const sourceParsed = row.source
|
|
136
|
+
? pluginSourceSchema.safeParse(row.source)
|
|
137
|
+
: undefined;
|
|
138
|
+
return {
|
|
139
|
+
name: row.name,
|
|
140
|
+
version: row.version ?? "",
|
|
141
|
+
type: typeParsed.success ? typeParsed.data : "backend",
|
|
142
|
+
enabled: row.enabled,
|
|
143
|
+
isUninstallable: row.isUninstallable,
|
|
144
|
+
isPrimary: row.isPrimary,
|
|
145
|
+
bundleId: row.bundleId,
|
|
146
|
+
source: sourceParsed?.success ? sourceParsed.data : null,
|
|
147
|
+
metadata,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
return { plugins: list };
|
|
151
|
+
}),
|
|
152
|
+
|
|
153
|
+
previewInstall: impl.previewInstall.handler(({ input }) =>
|
|
154
|
+
withTranslatedErrors(() =>
|
|
155
|
+
previewInstallOriginator({
|
|
156
|
+
source: input.source,
|
|
157
|
+
registry,
|
|
158
|
+
workspaceRoot,
|
|
159
|
+
runtimeDir,
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
|
|
164
|
+
install: impl.install.handler(async ({ input, context }) => {
|
|
165
|
+
const userId = (context.user as { id?: string } | undefined)?.id;
|
|
166
|
+
const result = await withTranslatedErrors(() =>
|
|
167
|
+
installOriginator({
|
|
168
|
+
source: input.source,
|
|
169
|
+
pluginManager,
|
|
170
|
+
db,
|
|
171
|
+
registry,
|
|
172
|
+
workspaceRoot,
|
|
173
|
+
runtimeDir,
|
|
174
|
+
eventRecorder,
|
|
175
|
+
userId,
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
return {
|
|
179
|
+
success: true,
|
|
180
|
+
bundleId: result.bundleId,
|
|
181
|
+
installedPackages: result.installedPackages,
|
|
182
|
+
};
|
|
183
|
+
}),
|
|
184
|
+
|
|
185
|
+
previewUninstall: impl.previewUninstall.handler(({ input }) =>
|
|
186
|
+
withTranslatedErrors(() =>
|
|
187
|
+
previewUninstallOriginator({
|
|
188
|
+
pluginName: input.pluginName,
|
|
189
|
+
db,
|
|
190
|
+
}),
|
|
191
|
+
),
|
|
192
|
+
),
|
|
193
|
+
|
|
194
|
+
uninstall: impl.uninstall.handler(async ({ input, context }) => {
|
|
195
|
+
const userId = (context.user as { id?: string } | undefined)?.id;
|
|
196
|
+
const result = await withTranslatedErrors(() =>
|
|
197
|
+
uninstallOriginator({
|
|
198
|
+
pluginName: input.pluginName,
|
|
199
|
+
deleteSchema: input.deleteSchema,
|
|
200
|
+
deleteConfigs: input.deleteConfigs,
|
|
201
|
+
cascade: input.cascade,
|
|
202
|
+
pluginManager,
|
|
203
|
+
db,
|
|
204
|
+
eventRecorder,
|
|
205
|
+
userId,
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
return { success: true, uninstalledPackages: result.uninstalledPackages };
|
|
209
|
+
}),
|
|
210
|
+
|
|
211
|
+
events: impl.events.handler(async ({ input }) => {
|
|
212
|
+
const events = await eventRecorder.list({
|
|
213
|
+
pluginName: input.pluginName,
|
|
214
|
+
limit: input.limit,
|
|
215
|
+
});
|
|
216
|
+
return { events };
|
|
217
|
+
}),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -4,6 +4,9 @@ import type {
|
|
|
4
4
|
SwitchResult,
|
|
5
5
|
RecurringJobInfo,
|
|
6
6
|
QueueStats,
|
|
7
|
+
JobSummary,
|
|
8
|
+
ListJobsOptions,
|
|
9
|
+
ListJobsResult,
|
|
7
10
|
} from "@checkstack/queue-api";
|
|
8
11
|
import type { QueuePluginRegistryImpl } from "./queue-plugin-registry";
|
|
9
12
|
import type { Logger, ConfigService } from "@checkstack/backend-api";
|
|
@@ -276,7 +279,11 @@ export class QueueManagerImpl implements QueueManager {
|
|
|
276
279
|
}
|
|
277
280
|
|
|
278
281
|
async getAggregatedStats(): Promise<QueueStats> {
|
|
279
|
-
|
|
282
|
+
// The narrowest scope wins: if any queue reports `instance`, the
|
|
283
|
+
// aggregate cannot claim cluster-wide accuracy.
|
|
284
|
+
let scope: "instance" | "cluster" = "cluster";
|
|
285
|
+
let sawAny = false;
|
|
286
|
+
const aggregated: Omit<QueueStats, "scope"> = {
|
|
280
287
|
pending: 0,
|
|
281
288
|
processing: 0,
|
|
282
289
|
completed: 0,
|
|
@@ -294,13 +301,81 @@ export class QueueManagerImpl implements QueueManager {
|
|
|
294
301
|
aggregated.completed += stats.completed;
|
|
295
302
|
aggregated.failed += stats.failed;
|
|
296
303
|
aggregated.consumerGroups += stats.consumerGroups;
|
|
304
|
+
if (stats.scope === "instance") {
|
|
305
|
+
scope = "instance";
|
|
306
|
+
}
|
|
307
|
+
sawAny = true;
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// Queue may not be initialized yet
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return { ...aggregated, scope: sawAny ? scope : "instance" };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Aggregate `listJobs` across every queue proxy. For deployments with a
|
|
319
|
+
* single queue (the common case) this is a straight pass-through; with
|
|
320
|
+
* multiple queues we over-fetch [0, offset+limit) per queue, merge-sort
|
|
321
|
+
* by the same key the per-queue impls use, then slice the requested
|
|
322
|
+
* window. Sort key:
|
|
323
|
+
* - completed/failed → finishedAt desc (most-recent first)
|
|
324
|
+
* - active → startedAt asc (oldest still-running first)
|
|
325
|
+
* - waiting/delayed → enqueuedAt asc (FIFO)
|
|
326
|
+
*/
|
|
327
|
+
async listJobs(opts: ListJobsOptions): Promise<ListJobsResult> {
|
|
328
|
+
const limit = Math.max(0, opts.limit);
|
|
329
|
+
const offset = Math.max(0, opts.offset);
|
|
330
|
+
if (limit === 0) {
|
|
331
|
+
return { items: [], total: 0, hasMore: false };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const fetchUpTo = offset + limit;
|
|
335
|
+
const merged: JobSummary[] = [];
|
|
336
|
+
let totalSum = 0;
|
|
337
|
+
let totalIsNull = false;
|
|
338
|
+
|
|
339
|
+
for (const proxy of this.queueProxies.values()) {
|
|
340
|
+
try {
|
|
341
|
+
const delegate = proxy.getDelegate();
|
|
342
|
+
if (delegate) {
|
|
343
|
+
const part = await delegate.listJobs({
|
|
344
|
+
state: opts.state,
|
|
345
|
+
offset: 0,
|
|
346
|
+
limit: fetchUpTo,
|
|
347
|
+
});
|
|
348
|
+
merged.push(...part.items);
|
|
349
|
+
if (part.total === null) {
|
|
350
|
+
totalIsNull = true;
|
|
351
|
+
} else {
|
|
352
|
+
totalSum += part.total;
|
|
353
|
+
}
|
|
297
354
|
}
|
|
298
355
|
} catch {
|
|
299
356
|
// Queue may not be initialized yet
|
|
300
357
|
}
|
|
301
358
|
}
|
|
302
359
|
|
|
303
|
-
|
|
360
|
+
const sortKey = (j: JobSummary): number => {
|
|
361
|
+
if (opts.state === "completed" || opts.state === "failed") {
|
|
362
|
+
return -(j.finishedAt?.getTime() ?? 0);
|
|
363
|
+
}
|
|
364
|
+
if (opts.state === "active") {
|
|
365
|
+
return j.startedAt?.getTime() ?? 0;
|
|
366
|
+
}
|
|
367
|
+
return j.enqueuedAt.getTime();
|
|
368
|
+
};
|
|
369
|
+
merged.sort((a, b) => sortKey(a) - sortKey(b));
|
|
370
|
+
|
|
371
|
+
const items = merged.slice(offset, offset + limit);
|
|
372
|
+
const total = totalIsNull ? null : totalSum;
|
|
373
|
+
const hasMore =
|
|
374
|
+
total === null
|
|
375
|
+
? merged.length > offset + items.length
|
|
376
|
+
: offset + items.length < total;
|
|
377
|
+
|
|
378
|
+
return { items, total, hasMore };
|
|
304
379
|
}
|
|
305
380
|
|
|
306
381
|
async listAllRecurringJobs(): Promise<RecurringJobInfo[]> {
|
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
QueueStats,
|
|
6
6
|
RecurringJobDetails,
|
|
7
7
|
RecurringSchedule,
|
|
8
|
+
ListJobsOptions,
|
|
9
|
+
ListJobsResult,
|
|
8
10
|
} from "@checkstack/queue-api";
|
|
9
11
|
import { rootLogger } from "../logger";
|
|
10
12
|
|
|
@@ -173,6 +175,11 @@ export class QueueProxy<T = unknown> implements Queue<T> {
|
|
|
173
175
|
return delegate.getStats();
|
|
174
176
|
}
|
|
175
177
|
|
|
178
|
+
async listJobs(opts: ListJobsOptions): Promise<ListJobsResult> {
|
|
179
|
+
const delegate = this.ensureDelegate();
|
|
180
|
+
return delegate.listJobs(opts);
|
|
181
|
+
}
|
|
182
|
+
|
|
176
183
|
/**
|
|
177
184
|
* Get subscription count (for testing/debugging)
|
|
178
185
|
*/
|
|
@@ -51,6 +51,7 @@ describe("extractPluginMetadata", () => {
|
|
|
51
51
|
pluginPath: "/fake/path/test-backend",
|
|
52
52
|
type: "backend",
|
|
53
53
|
enabled: true,
|
|
54
|
+
version: "0.0.1",
|
|
54
55
|
});
|
|
55
56
|
});
|
|
56
57
|
|
|
@@ -288,6 +289,7 @@ describe("syncPluginsToDatabase", () => {
|
|
|
288
289
|
pluginPath: "/workspace/plugins/new-backend",
|
|
289
290
|
type: "backend",
|
|
290
291
|
enabled: true,
|
|
292
|
+
version: "2.1.0",
|
|
291
293
|
},
|
|
292
294
|
];
|
|
293
295
|
|
|
@@ -310,6 +312,7 @@ describe("syncPluginsToDatabase", () => {
|
|
|
310
312
|
type: "backend",
|
|
311
313
|
enabled: true,
|
|
312
314
|
isUninstallable: false,
|
|
315
|
+
version: "2.1.0",
|
|
313
316
|
});
|
|
314
317
|
});
|
|
315
318
|
|
|
@@ -320,6 +323,7 @@ describe("syncPluginsToDatabase", () => {
|
|
|
320
323
|
pluginPath: "/workspace/plugins/new-location",
|
|
321
324
|
type: "backend",
|
|
322
325
|
enabled: true,
|
|
326
|
+
version: "1.0.0",
|
|
323
327
|
},
|
|
324
328
|
];
|
|
325
329
|
|
|
@@ -347,6 +351,7 @@ describe("syncPluginsToDatabase", () => {
|
|
|
347
351
|
expect(updateCall.set).toHaveBeenCalledWith({
|
|
348
352
|
path: "/workspace/plugins/new-location",
|
|
349
353
|
type: "backend",
|
|
354
|
+
version: "1.0.0",
|
|
350
355
|
});
|
|
351
356
|
});
|
|
352
357
|
|
|
@@ -357,6 +362,7 @@ describe("syncPluginsToDatabase", () => {
|
|
|
357
362
|
pluginPath: "/workspace/plugins/remote-backend",
|
|
358
363
|
type: "backend",
|
|
359
364
|
enabled: true,
|
|
365
|
+
version: "0.0.0",
|
|
360
366
|
},
|
|
361
367
|
];
|
|
362
368
|
|
|
@@ -10,6 +10,7 @@ export interface PluginMetadata {
|
|
|
10
10
|
pluginPath: string; // Absolute path to plugin directory
|
|
11
11
|
type: "backend" | "frontend" | "common";
|
|
12
12
|
enabled: boolean;
|
|
13
|
+
version: string; // From package.json "version"
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -64,6 +65,7 @@ export function extractPluginMetadata({
|
|
|
64
65
|
pluginPath: pluginDir,
|
|
65
66
|
type,
|
|
66
67
|
enabled: true, // Local plugins are always enabled
|
|
68
|
+
version: typeof pkgJson.version === "string" ? pkgJson.version : "",
|
|
67
69
|
};
|
|
68
70
|
} catch (error) {
|
|
69
71
|
rootLogger.debug(`⚠️ Failed to read package.json for ${pluginDir}:`, error);
|
|
@@ -148,16 +150,19 @@ export async function syncPluginsToDatabase({
|
|
|
148
150
|
type: plugin.type,
|
|
149
151
|
enabled: plugin.enabled,
|
|
150
152
|
isUninstallable: false, // Local plugins are part of monorepo
|
|
153
|
+
version: plugin.version,
|
|
151
154
|
});
|
|
152
155
|
} else {
|
|
153
156
|
// Update existing plugin ONLY if it's a local plugin (not remotely installed)
|
|
154
|
-
// This handles the case where a plugin directory was renamed
|
|
157
|
+
// This handles the case where a plugin directory was renamed AND keeps
|
|
158
|
+
// `version` in lockstep with each release.
|
|
155
159
|
if (!existing[0].isUninstallable) {
|
|
156
160
|
await db
|
|
157
161
|
.update(plugins)
|
|
158
162
|
.set({
|
|
159
163
|
path: plugin.pluginPath,
|
|
160
164
|
type: plugin.type,
|
|
165
|
+
version: plugin.version,
|
|
161
166
|
})
|
|
162
167
|
.where(
|
|
163
168
|
and(
|