donobu 5.24.0 → 5.25.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/dist/cli/install-donobu-plugin.d.ts +19 -0
- package/dist/cli/install-donobu-plugin.js +286 -22
- package/dist/esm/cli/install-donobu-plugin.d.ts +19 -0
- package/dist/esm/cli/install-donobu-plugin.js +286 -22
- package/dist/esm/managers/PluginLoader.d.ts +33 -7
- package/dist/esm/managers/PluginLoader.js +102 -37
- package/dist/managers/PluginLoader.d.ts +33 -7
- package/dist/managers/PluginLoader.js +102 -37
- package/package.json +1 -1
|
@@ -2,5 +2,24 @@
|
|
|
2
2
|
export declare function getPluginName(): Promise<string>;
|
|
3
3
|
export declare function buildPlugin(): Promise<void>;
|
|
4
4
|
export declare function installPlugin(): Promise<void>;
|
|
5
|
+
type ReleaseSpec = {
|
|
6
|
+
/** Plugin name as it appears on the tarball asset and as the install dir. */
|
|
7
|
+
name: string;
|
|
8
|
+
/** Pinned version, or `undefined` to resolve the latest. */
|
|
9
|
+
version: string | undefined;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Parses `name`, `name@version`, `@scope/name`, or `@scope/name@version`.
|
|
13
|
+
* The last `@` after the first character (if any) separates name and version.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseReleaseSpec(spec: string): ReleaseSpec;
|
|
16
|
+
export declare function installFromRelease(spec: ReleaseSpec, apiKey: string, into: string | undefined): Promise<void>;
|
|
17
|
+
type ParsedArgs = {
|
|
18
|
+
fromRelease: string | undefined;
|
|
19
|
+
apiKey: string | undefined;
|
|
20
|
+
into: string | undefined;
|
|
21
|
+
};
|
|
22
|
+
export declare function parseArgs(argv: string[]): ParsedArgs;
|
|
5
23
|
export declare function main(): Promise<void>;
|
|
24
|
+
export {};
|
|
6
25
|
//# sourceMappingURL=install-donobu-plugin.d.ts.map
|
|
@@ -4,46 +4,57 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
exports.getPluginName = getPluginName;
|
|
5
5
|
exports.buildPlugin = buildPlugin;
|
|
6
6
|
exports.installPlugin = installPlugin;
|
|
7
|
+
exports.parseReleaseSpec = parseReleaseSpec;
|
|
8
|
+
exports.installFromRelease = installFromRelease;
|
|
9
|
+
exports.parseArgs = parseArgs;
|
|
7
10
|
exports.main = main;
|
|
8
11
|
/**
|
|
9
12
|
* @fileoverview Donobu Plugin Installation CLI
|
|
10
13
|
*
|
|
11
|
-
* A command-line utility for
|
|
12
|
-
*
|
|
13
|
-
* where Donobu Studio can discover and load it.
|
|
14
|
+
* A command-line utility for installing Donobu plugins into the local Donobu
|
|
15
|
+
* Studio environment. Two modes:
|
|
14
16
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* 1. **Local build-and-copy** (default): run from a plugin source directory
|
|
18
|
+
* with `package.json` and `src/`. Builds via `npm run build` and copies
|
|
19
|
+
* `dist/` into Donobu's plugins directory.
|
|
20
|
+
*
|
|
21
|
+
* 2. **Release fetch** (`--from-release`): downloads a pre-built first-party
|
|
22
|
+
* plugin bundle from the Donobu API (which checks the customer's
|
|
23
|
+
* account entitlement before serving bytes from the private release
|
|
24
|
+
* registry), verifies the sha256, and extracts it into Donobu's plugins
|
|
25
|
+
* directory. No local project required.
|
|
17
26
|
*
|
|
27
|
+
* @usage
|
|
18
28
|
* ```bash
|
|
29
|
+
* # Local mode
|
|
19
30
|
* npm exec install-donobu-plugin
|
|
20
|
-
* ```
|
|
21
31
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* 3. Runs `npm run build` to compile the plugin.
|
|
26
|
-
* 4. Copies the built plugin to the Donobu plugin directory (OS-variant).
|
|
27
|
-
* 5. Provides instructions to restart Donobu Studio to see the new tools.
|
|
32
|
+
* # Release mode (latest)
|
|
33
|
+
* DONOBU_API_KEY=DB_… npx install-donobu-plugin \
|
|
34
|
+
* --from-release @donobu/donobu-mobile
|
|
28
35
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* - Write permissions to the Donobu Studio plugins directory.
|
|
34
|
-
*
|
|
35
|
-
* @output
|
|
36
|
-
* - Plugin compiled and installed to Donobu Studio plugins directory.
|
|
37
|
-
* - Console output with installation progress and final location.
|
|
36
|
+
* # Release mode (pinned version)
|
|
37
|
+
* DONOBU_API_KEY=DB_… npx install-donobu-plugin \
|
|
38
|
+
* --from-release @donobu/donobu-mobile@1.0.0
|
|
39
|
+
* ```
|
|
38
40
|
*/
|
|
39
41
|
const child_process_1 = require("child_process");
|
|
42
|
+
const crypto_1 = require("crypto");
|
|
40
43
|
const fs_1 = require("fs");
|
|
41
44
|
const promises_1 = require("fs/promises");
|
|
45
|
+
const os_1 = require("os");
|
|
42
46
|
const path_1 = require("path");
|
|
47
|
+
const promises_2 = require("stream/promises");
|
|
43
48
|
const util_1 = require("util");
|
|
44
49
|
const Logger_1 = require("../utils/Logger");
|
|
45
50
|
const MiscUtils_1 = require("../utils/MiscUtils");
|
|
46
51
|
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
52
|
+
/**
|
|
53
|
+
* Default Donobu API base URL. Matches the default in `envVars.ts` so that a
|
|
54
|
+
* customer with a production `DONOBU_API_KEY` needs no extra configuration
|
|
55
|
+
* to install plugins. Override for staging via `DONOBU_API_BASE_URL`.
|
|
56
|
+
*/
|
|
57
|
+
const DEFAULT_DONOBU_API_BASE_URL = 'https://donobu-prd-api-service-73193699649.us-central1.run.app';
|
|
47
58
|
async function getPluginName() {
|
|
48
59
|
try {
|
|
49
60
|
// First, try to read the plugin name from package.json
|
|
@@ -95,9 +106,262 @@ async function installPlugin() {
|
|
|
95
106
|
Logger_1.appLogger.info(`Plugin installed successfully to: ${thisPluginDir}`);
|
|
96
107
|
Logger_1.appLogger.info('Restart Donobu to load the plugin.');
|
|
97
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Parses `name`, `name@version`, `@scope/name`, or `@scope/name@version`.
|
|
111
|
+
* The last `@` after the first character (if any) separates name and version.
|
|
112
|
+
*/
|
|
113
|
+
function parseReleaseSpec(spec) {
|
|
114
|
+
if (!spec) {
|
|
115
|
+
throw new Error('--from-release requires a plugin name');
|
|
116
|
+
}
|
|
117
|
+
const atIdx = spec.lastIndexOf('@');
|
|
118
|
+
if (atIdx > 0) {
|
|
119
|
+
return { name: spec.slice(0, atIdx), version: spec.slice(atIdx + 1) };
|
|
120
|
+
}
|
|
121
|
+
return { name: spec, version: undefined };
|
|
122
|
+
}
|
|
123
|
+
function getDonobuApiBaseUrl() {
|
|
124
|
+
return (process.env.DONOBU_API_BASE_URL?.replace(/\/$/, '') ??
|
|
125
|
+
DEFAULT_DONOBU_API_BASE_URL);
|
|
126
|
+
}
|
|
127
|
+
function getDonobuApiKey(explicit) {
|
|
128
|
+
const key = explicit ?? process.env.DONOBU_API_KEY;
|
|
129
|
+
if (!key) {
|
|
130
|
+
throw new Error('Missing DONOBU_API_KEY. Pass --api-key <key>, set DONOBU_API_KEY in ' +
|
|
131
|
+
'your environment, or retrieve a key at https://donobu.com/account/keys.');
|
|
132
|
+
}
|
|
133
|
+
return key;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Surface donobu-api JSON error bodies (`{ error, message }`) in the CLI's
|
|
137
|
+
* error output, so customers see the server's description instead of a bare
|
|
138
|
+
* HTTP status code.
|
|
139
|
+
*/
|
|
140
|
+
async function extractApiErrorMessage(res) {
|
|
141
|
+
try {
|
|
142
|
+
const body = (await res.clone().json());
|
|
143
|
+
if (body.message) {
|
|
144
|
+
return body.message;
|
|
145
|
+
}
|
|
146
|
+
if (body.error) {
|
|
147
|
+
return body.error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* fall through */
|
|
152
|
+
}
|
|
153
|
+
return `${res.status} ${res.statusText}`;
|
|
154
|
+
}
|
|
155
|
+
async function fetchReleaseMetadata(baseUrl, apiKey, spec) {
|
|
156
|
+
const params = new URLSearchParams({
|
|
157
|
+
name: spec.name,
|
|
158
|
+
version: spec.version ?? 'latest',
|
|
159
|
+
});
|
|
160
|
+
const url = `${baseUrl}/v1/plugins/release?${params.toString()}`;
|
|
161
|
+
const res = await fetch(url, {
|
|
162
|
+
headers: {
|
|
163
|
+
Accept: 'application/json',
|
|
164
|
+
Authorization: `Bearer ${apiKey}`,
|
|
165
|
+
'User-Agent': 'install-donobu-plugin',
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
throw new Error(`Failed to resolve ${spec.name}@${spec.version ?? 'latest'}: ` +
|
|
170
|
+
(await extractApiErrorMessage(res)));
|
|
171
|
+
}
|
|
172
|
+
return (await res.json());
|
|
173
|
+
}
|
|
174
|
+
async function downloadTarball(baseUrl, apiKey, name, version, destPath) {
|
|
175
|
+
const params = new URLSearchParams({ name, version });
|
|
176
|
+
const url = `${baseUrl}/v1/plugins/release/tarball?${params.toString()}`;
|
|
177
|
+
const res = await fetch(url, {
|
|
178
|
+
headers: {
|
|
179
|
+
Accept: 'application/octet-stream',
|
|
180
|
+
Authorization: `Bearer ${apiKey}`,
|
|
181
|
+
'User-Agent': 'install-donobu-plugin',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
if (!res.ok || !res.body) {
|
|
185
|
+
throw new Error(`Failed to download ${name}@${version}: ` +
|
|
186
|
+
(await extractApiErrorMessage(res)));
|
|
187
|
+
}
|
|
188
|
+
await (0, promises_2.pipeline)(res.body, (0, fs_1.createWriteStream)(destPath));
|
|
189
|
+
}
|
|
190
|
+
async function fetchEntitlementsEnvelope(baseUrl, apiKey) {
|
|
191
|
+
const res = await fetch(`${baseUrl}/v1/entitlements`, {
|
|
192
|
+
headers: {
|
|
193
|
+
Accept: 'application/json',
|
|
194
|
+
Authorization: `Bearer ${apiKey}`,
|
|
195
|
+
'User-Agent': 'install-donobu-plugin',
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
if (!res.ok) {
|
|
199
|
+
throw new Error(`Failed to fetch entitlements: ` + (await extractApiErrorMessage(res)));
|
|
200
|
+
}
|
|
201
|
+
// Persist the exact JSON bytes the server sent. Reserializing would risk
|
|
202
|
+
// reordering keys in a way that invalidates the signature downstream.
|
|
203
|
+
const raw = await res.text();
|
|
204
|
+
return { raw, envelope: JSON.parse(raw) };
|
|
205
|
+
}
|
|
206
|
+
async function sha256OfFile(path) {
|
|
207
|
+
const hash = (0, crypto_1.createHash)('sha256');
|
|
208
|
+
const buf = await (0, promises_1.readFile)(path);
|
|
209
|
+
hash.update(buf);
|
|
210
|
+
return hash.digest('hex');
|
|
211
|
+
}
|
|
212
|
+
async function extractTarball(tarballPath, destDir) {
|
|
213
|
+
await (0, promises_1.mkdir)(destDir, { recursive: true });
|
|
214
|
+
// `tar` handles both gzip and xz transparently with `-xf`. `-C` sets the
|
|
215
|
+
// extraction root. Expect the tarball to contain the flat plugin payload
|
|
216
|
+
// (index.mjs, package.json, etc.) at its root — not nested under dist/.
|
|
217
|
+
await execAsync(`tar -xf "${tarballPath}" -C "${destDir}"`);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Filename for the signed entitlement envelope when colocated with a
|
|
221
|
+
* plugin's extracted bundle. Matches the constant in the plugin's
|
|
222
|
+
* entitlement-check code; changing one without the other breaks license
|
|
223
|
+
* discovery.
|
|
224
|
+
*/
|
|
225
|
+
const LICENSE_FILENAME = '.donobu-license.json';
|
|
226
|
+
/**
|
|
227
|
+
* Resolve the parent directory into which the plugin's own dir will be
|
|
228
|
+
* placed. Without `--into`, we use the default `<app-data>/plugins/`
|
|
229
|
+
* location the Donobu Studio plugin loader scans. With `--into`, we
|
|
230
|
+
* honor the caller's path (useful for code-based test setups that want
|
|
231
|
+
* the plugin discoverable inside their own `node_modules/@donobu/`).
|
|
232
|
+
*/
|
|
233
|
+
function resolveInstallParentDir(into) {
|
|
234
|
+
if (into) {
|
|
235
|
+
return (0, path_1.resolve)(into);
|
|
236
|
+
}
|
|
237
|
+
return (0, path_1.join)(MiscUtils_1.MiscUtils.baseWorkingDirectory(), 'plugins');
|
|
238
|
+
}
|
|
239
|
+
async function installFromRelease(spec, apiKey, into) {
|
|
240
|
+
const baseUrl = getDonobuApiBaseUrl();
|
|
241
|
+
Logger_1.appLogger.info(`Resolving ${spec.name}@${spec.version ?? 'latest'} from ${baseUrl}...`);
|
|
242
|
+
const { name, version, manifest } = await fetchReleaseMetadata(baseUrl, apiKey, spec);
|
|
243
|
+
if (manifest.name !== name || manifest.version !== version) {
|
|
244
|
+
throw new Error(`Manifest mismatch: expected ${name}@${version}, got ${manifest.name}@${manifest.version}.`);
|
|
245
|
+
}
|
|
246
|
+
// Fetch the signed entitlement envelope before the tarball download so we
|
|
247
|
+
// fail fast if the server would not recognize the grant anyway. The
|
|
248
|
+
// metadata call above already gates on entitlement, but this also catches
|
|
249
|
+
// races (e.g. revocation between the two calls) and gives us the signed
|
|
250
|
+
// payload gated plugins require at load time.
|
|
251
|
+
Logger_1.appLogger.info('Fetching signed entitlement envelope...');
|
|
252
|
+
const { raw: envelopeJson, envelope } = await fetchEntitlementsEnvelope(baseUrl, apiKey);
|
|
253
|
+
if (!envelope.payload.entitlements[name]) {
|
|
254
|
+
throw new Error(`Entitlement envelope does not contain a grant for ${name}. ` +
|
|
255
|
+
`The grant may have been revoked during install; try again.`);
|
|
256
|
+
}
|
|
257
|
+
const workDir = await (0, promises_1.mkdtemp)((0, path_1.join)((0, os_1.tmpdir)(), 'donobu-plugin-'));
|
|
258
|
+
try {
|
|
259
|
+
const tarPath = (0, path_1.join)(workDir, manifest.tarball);
|
|
260
|
+
Logger_1.appLogger.info(`Downloading ${manifest.tarball}...`);
|
|
261
|
+
await downloadTarball(baseUrl, apiKey, name, version, tarPath);
|
|
262
|
+
const actualSha = await sha256OfFile(tarPath);
|
|
263
|
+
if (actualSha.toLowerCase() !== manifest.sha256.toLowerCase()) {
|
|
264
|
+
throw new Error(`Checksum mismatch for ${manifest.tarball} — refusing to install. ` +
|
|
265
|
+
`Expected ${manifest.sha256}, got ${actualSha}.`);
|
|
266
|
+
}
|
|
267
|
+
// Extract into a staging dir inside workDir first, then atomically swap
|
|
268
|
+
// into the final plugins location to avoid half-written plugin state.
|
|
269
|
+
// License file goes inside the staging dir before the swap so the
|
|
270
|
+
// post-rename plugin dir always has both bundle + license present at
|
|
271
|
+
// the same instant — no half-installed window.
|
|
272
|
+
const stageDir = (0, path_1.join)(workDir, 'stage');
|
|
273
|
+
await extractTarball(tarPath, stageDir);
|
|
274
|
+
await (0, promises_1.writeFile)((0, path_1.join)(stageDir, LICENSE_FILENAME), envelopeJson + '\n', 'utf8');
|
|
275
|
+
const parentDir = resolveInstallParentDir(into);
|
|
276
|
+
const destDir = (0, path_1.join)(parentDir, name);
|
|
277
|
+
await (0, promises_1.mkdir)(parentDir, { recursive: true });
|
|
278
|
+
// For scoped names like @donobu/donobu-mobile, ensure the @scope parent exists.
|
|
279
|
+
await (0, promises_1.mkdir)((0, path_1.join)(destDir, '..'), { recursive: true });
|
|
280
|
+
await (0, promises_1.rm)(destDir, { recursive: true, force: true });
|
|
281
|
+
await (0, promises_1.rename)(stageDir, destDir);
|
|
282
|
+
const grantExpiresAt = envelope.payload.entitlements[name].expiresAt;
|
|
283
|
+
const expiryDescription = grantExpiresAt === null ? 'perpetual (never expires)' : grantExpiresAt;
|
|
284
|
+
Logger_1.appLogger.info(`Installed ${name}@${version} to ${destDir} ` +
|
|
285
|
+
`(license colocated, entitlement ${expiryDescription}). ` +
|
|
286
|
+
`Restart Donobu to load the plugin.`);
|
|
287
|
+
// Soft-warn when the customer is installing very close to the grant's
|
|
288
|
+
// expiry — catches the "I installed today but it already stopped working"
|
|
289
|
+
// footgun.
|
|
290
|
+
if (grantExpiresAt !== null) {
|
|
291
|
+
const daysUntilExpiry = Math.floor((new Date(grantExpiresAt).getTime() - Date.now()) / 86_400_000);
|
|
292
|
+
if (daysUntilExpiry >= 0 && daysUntilExpiry < 7) {
|
|
293
|
+
Logger_1.appLogger.warn(`Your entitlement expires in ${daysUntilExpiry} day(s) ` +
|
|
294
|
+
`(${grantExpiresAt}). Contact support@donobu.com to renew before relying on this install.`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
await (0, promises_1.rm)(workDir, { recursive: true, force: true });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function parseArgs(argv) {
|
|
303
|
+
const args = argv.slice(2);
|
|
304
|
+
let fromRelease;
|
|
305
|
+
let apiKey;
|
|
306
|
+
let into;
|
|
307
|
+
for (let i = 0; i < args.length; i++) {
|
|
308
|
+
const a = args[i];
|
|
309
|
+
if (a === '--from-release' && i + 1 < args.length) {
|
|
310
|
+
fromRelease = args[++i];
|
|
311
|
+
}
|
|
312
|
+
else if (a.startsWith('--from-release=')) {
|
|
313
|
+
fromRelease = a.slice('--from-release='.length);
|
|
314
|
+
}
|
|
315
|
+
else if (a === '--api-key' && i + 1 < args.length) {
|
|
316
|
+
apiKey = args[++i];
|
|
317
|
+
}
|
|
318
|
+
else if (a.startsWith('--api-key=')) {
|
|
319
|
+
apiKey = a.slice('--api-key='.length);
|
|
320
|
+
}
|
|
321
|
+
else if (a === '--into' && i + 1 < args.length) {
|
|
322
|
+
into = args[++i];
|
|
323
|
+
}
|
|
324
|
+
else if (a.startsWith('--into=')) {
|
|
325
|
+
into = a.slice('--into='.length);
|
|
326
|
+
}
|
|
327
|
+
else if (a === '--help' || a === '-h') {
|
|
328
|
+
printUsage();
|
|
329
|
+
process.exit(0);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { fromRelease, apiKey, into };
|
|
333
|
+
}
|
|
334
|
+
function printUsage() {
|
|
335
|
+
console.log([
|
|
336
|
+
'Usage:',
|
|
337
|
+
' install-donobu-plugin # build & install from current dir',
|
|
338
|
+
' install-donobu-plugin --from-release <name>[@<version>] [--api-key <key>] [--into <dir>]',
|
|
339
|
+
'',
|
|
340
|
+
'Release-mode options:',
|
|
341
|
+
' --from-release <name>[@<version>] Install a pre-built Donobu plugin.',
|
|
342
|
+
' Omit @version to install the latest.',
|
|
343
|
+
' --api-key <key> Donobu API key (or set DONOBU_API_KEY).',
|
|
344
|
+
' Your account entitlement gates access.',
|
|
345
|
+
' --into <dir> Install the plugin under <dir>/<name>/',
|
|
346
|
+
' instead of the Donobu Studio plugins',
|
|
347
|
+
' directory. Useful for code-based test',
|
|
348
|
+
' setups — pass `./node_modules` to make',
|
|
349
|
+
' the plugin importable from your project.',
|
|
350
|
+
'',
|
|
351
|
+
'Examples:',
|
|
352
|
+
' install-donobu-plugin --from-release @donobu/donobu-mobile',
|
|
353
|
+
' install-donobu-plugin --from-release @donobu/donobu-mobile@1.0.0',
|
|
354
|
+
' install-donobu-plugin --from-release @donobu/donobu-mobile --into ./node_modules',
|
|
355
|
+
].join('\n'));
|
|
356
|
+
}
|
|
98
357
|
async function main() {
|
|
358
|
+
const { fromRelease, apiKey, into } = parseArgs(process.argv);
|
|
99
359
|
try {
|
|
100
|
-
|
|
360
|
+
if (fromRelease) {
|
|
361
|
+
await installFromRelease(parseReleaseSpec(fromRelease), getDonobuApiKey(apiKey), into);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// Local build-and-copy mode — must be run from a plugin source directory.
|
|
101
365
|
try {
|
|
102
366
|
await (0, promises_1.access)('package.json', fs_1.constants.F_OK);
|
|
103
367
|
await (0, promises_1.access)('src', fs_1.constants.F_OK);
|
|
@@ -2,5 +2,24 @@
|
|
|
2
2
|
export declare function getPluginName(): Promise<string>;
|
|
3
3
|
export declare function buildPlugin(): Promise<void>;
|
|
4
4
|
export declare function installPlugin(): Promise<void>;
|
|
5
|
+
type ReleaseSpec = {
|
|
6
|
+
/** Plugin name as it appears on the tarball asset and as the install dir. */
|
|
7
|
+
name: string;
|
|
8
|
+
/** Pinned version, or `undefined` to resolve the latest. */
|
|
9
|
+
version: string | undefined;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Parses `name`, `name@version`, `@scope/name`, or `@scope/name@version`.
|
|
13
|
+
* The last `@` after the first character (if any) separates name and version.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseReleaseSpec(spec: string): ReleaseSpec;
|
|
16
|
+
export declare function installFromRelease(spec: ReleaseSpec, apiKey: string, into: string | undefined): Promise<void>;
|
|
17
|
+
type ParsedArgs = {
|
|
18
|
+
fromRelease: string | undefined;
|
|
19
|
+
apiKey: string | undefined;
|
|
20
|
+
into: string | undefined;
|
|
21
|
+
};
|
|
22
|
+
export declare function parseArgs(argv: string[]): ParsedArgs;
|
|
5
23
|
export declare function main(): Promise<void>;
|
|
24
|
+
export {};
|
|
6
25
|
//# sourceMappingURL=install-donobu-plugin.d.ts.map
|
|
@@ -4,46 +4,57 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
exports.getPluginName = getPluginName;
|
|
5
5
|
exports.buildPlugin = buildPlugin;
|
|
6
6
|
exports.installPlugin = installPlugin;
|
|
7
|
+
exports.parseReleaseSpec = parseReleaseSpec;
|
|
8
|
+
exports.installFromRelease = installFromRelease;
|
|
9
|
+
exports.parseArgs = parseArgs;
|
|
7
10
|
exports.main = main;
|
|
8
11
|
/**
|
|
9
12
|
* @fileoverview Donobu Plugin Installation CLI
|
|
10
13
|
*
|
|
11
|
-
* A command-line utility for
|
|
12
|
-
*
|
|
13
|
-
* where Donobu Studio can discover and load it.
|
|
14
|
+
* A command-line utility for installing Donobu plugins into the local Donobu
|
|
15
|
+
* Studio environment. Two modes:
|
|
14
16
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* 1. **Local build-and-copy** (default): run from a plugin source directory
|
|
18
|
+
* with `package.json` and `src/`. Builds via `npm run build` and copies
|
|
19
|
+
* `dist/` into Donobu's plugins directory.
|
|
20
|
+
*
|
|
21
|
+
* 2. **Release fetch** (`--from-release`): downloads a pre-built first-party
|
|
22
|
+
* plugin bundle from the Donobu API (which checks the customer's
|
|
23
|
+
* account entitlement before serving bytes from the private release
|
|
24
|
+
* registry), verifies the sha256, and extracts it into Donobu's plugins
|
|
25
|
+
* directory. No local project required.
|
|
17
26
|
*
|
|
27
|
+
* @usage
|
|
18
28
|
* ```bash
|
|
29
|
+
* # Local mode
|
|
19
30
|
* npm exec install-donobu-plugin
|
|
20
|
-
* ```
|
|
21
31
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* 3. Runs `npm run build` to compile the plugin.
|
|
26
|
-
* 4. Copies the built plugin to the Donobu plugin directory (OS-variant).
|
|
27
|
-
* 5. Provides instructions to restart Donobu Studio to see the new tools.
|
|
32
|
+
* # Release mode (latest)
|
|
33
|
+
* DONOBU_API_KEY=DB_… npx install-donobu-plugin \
|
|
34
|
+
* --from-release @donobu/donobu-mobile
|
|
28
35
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* - Write permissions to the Donobu Studio plugins directory.
|
|
34
|
-
*
|
|
35
|
-
* @output
|
|
36
|
-
* - Plugin compiled and installed to Donobu Studio plugins directory.
|
|
37
|
-
* - Console output with installation progress and final location.
|
|
36
|
+
* # Release mode (pinned version)
|
|
37
|
+
* DONOBU_API_KEY=DB_… npx install-donobu-plugin \
|
|
38
|
+
* --from-release @donobu/donobu-mobile@1.0.0
|
|
39
|
+
* ```
|
|
38
40
|
*/
|
|
39
41
|
const child_process_1 = require("child_process");
|
|
42
|
+
const crypto_1 = require("crypto");
|
|
40
43
|
const fs_1 = require("fs");
|
|
41
44
|
const promises_1 = require("fs/promises");
|
|
45
|
+
const os_1 = require("os");
|
|
42
46
|
const path_1 = require("path");
|
|
47
|
+
const promises_2 = require("stream/promises");
|
|
43
48
|
const util_1 = require("util");
|
|
44
49
|
const Logger_1 = require("../utils/Logger");
|
|
45
50
|
const MiscUtils_1 = require("../utils/MiscUtils");
|
|
46
51
|
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
52
|
+
/**
|
|
53
|
+
* Default Donobu API base URL. Matches the default in `envVars.ts` so that a
|
|
54
|
+
* customer with a production `DONOBU_API_KEY` needs no extra configuration
|
|
55
|
+
* to install plugins. Override for staging via `DONOBU_API_BASE_URL`.
|
|
56
|
+
*/
|
|
57
|
+
const DEFAULT_DONOBU_API_BASE_URL = 'https://donobu-prd-api-service-73193699649.us-central1.run.app';
|
|
47
58
|
async function getPluginName() {
|
|
48
59
|
try {
|
|
49
60
|
// First, try to read the plugin name from package.json
|
|
@@ -95,9 +106,262 @@ async function installPlugin() {
|
|
|
95
106
|
Logger_1.appLogger.info(`Plugin installed successfully to: ${thisPluginDir}`);
|
|
96
107
|
Logger_1.appLogger.info('Restart Donobu to load the plugin.');
|
|
97
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Parses `name`, `name@version`, `@scope/name`, or `@scope/name@version`.
|
|
111
|
+
* The last `@` after the first character (if any) separates name and version.
|
|
112
|
+
*/
|
|
113
|
+
function parseReleaseSpec(spec) {
|
|
114
|
+
if (!spec) {
|
|
115
|
+
throw new Error('--from-release requires a plugin name');
|
|
116
|
+
}
|
|
117
|
+
const atIdx = spec.lastIndexOf('@');
|
|
118
|
+
if (atIdx > 0) {
|
|
119
|
+
return { name: spec.slice(0, atIdx), version: spec.slice(atIdx + 1) };
|
|
120
|
+
}
|
|
121
|
+
return { name: spec, version: undefined };
|
|
122
|
+
}
|
|
123
|
+
function getDonobuApiBaseUrl() {
|
|
124
|
+
return (process.env.DONOBU_API_BASE_URL?.replace(/\/$/, '') ??
|
|
125
|
+
DEFAULT_DONOBU_API_BASE_URL);
|
|
126
|
+
}
|
|
127
|
+
function getDonobuApiKey(explicit) {
|
|
128
|
+
const key = explicit ?? process.env.DONOBU_API_KEY;
|
|
129
|
+
if (!key) {
|
|
130
|
+
throw new Error('Missing DONOBU_API_KEY. Pass --api-key <key>, set DONOBU_API_KEY in ' +
|
|
131
|
+
'your environment, or retrieve a key at https://donobu.com/account/keys.');
|
|
132
|
+
}
|
|
133
|
+
return key;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Surface donobu-api JSON error bodies (`{ error, message }`) in the CLI's
|
|
137
|
+
* error output, so customers see the server's description instead of a bare
|
|
138
|
+
* HTTP status code.
|
|
139
|
+
*/
|
|
140
|
+
async function extractApiErrorMessage(res) {
|
|
141
|
+
try {
|
|
142
|
+
const body = (await res.clone().json());
|
|
143
|
+
if (body.message) {
|
|
144
|
+
return body.message;
|
|
145
|
+
}
|
|
146
|
+
if (body.error) {
|
|
147
|
+
return body.error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* fall through */
|
|
152
|
+
}
|
|
153
|
+
return `${res.status} ${res.statusText}`;
|
|
154
|
+
}
|
|
155
|
+
async function fetchReleaseMetadata(baseUrl, apiKey, spec) {
|
|
156
|
+
const params = new URLSearchParams({
|
|
157
|
+
name: spec.name,
|
|
158
|
+
version: spec.version ?? 'latest',
|
|
159
|
+
});
|
|
160
|
+
const url = `${baseUrl}/v1/plugins/release?${params.toString()}`;
|
|
161
|
+
const res = await fetch(url, {
|
|
162
|
+
headers: {
|
|
163
|
+
Accept: 'application/json',
|
|
164
|
+
Authorization: `Bearer ${apiKey}`,
|
|
165
|
+
'User-Agent': 'install-donobu-plugin',
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
throw new Error(`Failed to resolve ${spec.name}@${spec.version ?? 'latest'}: ` +
|
|
170
|
+
(await extractApiErrorMessage(res)));
|
|
171
|
+
}
|
|
172
|
+
return (await res.json());
|
|
173
|
+
}
|
|
174
|
+
async function downloadTarball(baseUrl, apiKey, name, version, destPath) {
|
|
175
|
+
const params = new URLSearchParams({ name, version });
|
|
176
|
+
const url = `${baseUrl}/v1/plugins/release/tarball?${params.toString()}`;
|
|
177
|
+
const res = await fetch(url, {
|
|
178
|
+
headers: {
|
|
179
|
+
Accept: 'application/octet-stream',
|
|
180
|
+
Authorization: `Bearer ${apiKey}`,
|
|
181
|
+
'User-Agent': 'install-donobu-plugin',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
if (!res.ok || !res.body) {
|
|
185
|
+
throw new Error(`Failed to download ${name}@${version}: ` +
|
|
186
|
+
(await extractApiErrorMessage(res)));
|
|
187
|
+
}
|
|
188
|
+
await (0, promises_2.pipeline)(res.body, (0, fs_1.createWriteStream)(destPath));
|
|
189
|
+
}
|
|
190
|
+
async function fetchEntitlementsEnvelope(baseUrl, apiKey) {
|
|
191
|
+
const res = await fetch(`${baseUrl}/v1/entitlements`, {
|
|
192
|
+
headers: {
|
|
193
|
+
Accept: 'application/json',
|
|
194
|
+
Authorization: `Bearer ${apiKey}`,
|
|
195
|
+
'User-Agent': 'install-donobu-plugin',
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
if (!res.ok) {
|
|
199
|
+
throw new Error(`Failed to fetch entitlements: ` + (await extractApiErrorMessage(res)));
|
|
200
|
+
}
|
|
201
|
+
// Persist the exact JSON bytes the server sent. Reserializing would risk
|
|
202
|
+
// reordering keys in a way that invalidates the signature downstream.
|
|
203
|
+
const raw = await res.text();
|
|
204
|
+
return { raw, envelope: JSON.parse(raw) };
|
|
205
|
+
}
|
|
206
|
+
async function sha256OfFile(path) {
|
|
207
|
+
const hash = (0, crypto_1.createHash)('sha256');
|
|
208
|
+
const buf = await (0, promises_1.readFile)(path);
|
|
209
|
+
hash.update(buf);
|
|
210
|
+
return hash.digest('hex');
|
|
211
|
+
}
|
|
212
|
+
async function extractTarball(tarballPath, destDir) {
|
|
213
|
+
await (0, promises_1.mkdir)(destDir, { recursive: true });
|
|
214
|
+
// `tar` handles both gzip and xz transparently with `-xf`. `-C` sets the
|
|
215
|
+
// extraction root. Expect the tarball to contain the flat plugin payload
|
|
216
|
+
// (index.mjs, package.json, etc.) at its root — not nested under dist/.
|
|
217
|
+
await execAsync(`tar -xf "${tarballPath}" -C "${destDir}"`);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Filename for the signed entitlement envelope when colocated with a
|
|
221
|
+
* plugin's extracted bundle. Matches the constant in the plugin's
|
|
222
|
+
* entitlement-check code; changing one without the other breaks license
|
|
223
|
+
* discovery.
|
|
224
|
+
*/
|
|
225
|
+
const LICENSE_FILENAME = '.donobu-license.json';
|
|
226
|
+
/**
|
|
227
|
+
* Resolve the parent directory into which the plugin's own dir will be
|
|
228
|
+
* placed. Without `--into`, we use the default `<app-data>/plugins/`
|
|
229
|
+
* location the Donobu Studio plugin loader scans. With `--into`, we
|
|
230
|
+
* honor the caller's path (useful for code-based test setups that want
|
|
231
|
+
* the plugin discoverable inside their own `node_modules/@donobu/`).
|
|
232
|
+
*/
|
|
233
|
+
function resolveInstallParentDir(into) {
|
|
234
|
+
if (into) {
|
|
235
|
+
return (0, path_1.resolve)(into);
|
|
236
|
+
}
|
|
237
|
+
return (0, path_1.join)(MiscUtils_1.MiscUtils.baseWorkingDirectory(), 'plugins');
|
|
238
|
+
}
|
|
239
|
+
async function installFromRelease(spec, apiKey, into) {
|
|
240
|
+
const baseUrl = getDonobuApiBaseUrl();
|
|
241
|
+
Logger_1.appLogger.info(`Resolving ${spec.name}@${spec.version ?? 'latest'} from ${baseUrl}...`);
|
|
242
|
+
const { name, version, manifest } = await fetchReleaseMetadata(baseUrl, apiKey, spec);
|
|
243
|
+
if (manifest.name !== name || manifest.version !== version) {
|
|
244
|
+
throw new Error(`Manifest mismatch: expected ${name}@${version}, got ${manifest.name}@${manifest.version}.`);
|
|
245
|
+
}
|
|
246
|
+
// Fetch the signed entitlement envelope before the tarball download so we
|
|
247
|
+
// fail fast if the server would not recognize the grant anyway. The
|
|
248
|
+
// metadata call above already gates on entitlement, but this also catches
|
|
249
|
+
// races (e.g. revocation between the two calls) and gives us the signed
|
|
250
|
+
// payload gated plugins require at load time.
|
|
251
|
+
Logger_1.appLogger.info('Fetching signed entitlement envelope...');
|
|
252
|
+
const { raw: envelopeJson, envelope } = await fetchEntitlementsEnvelope(baseUrl, apiKey);
|
|
253
|
+
if (!envelope.payload.entitlements[name]) {
|
|
254
|
+
throw new Error(`Entitlement envelope does not contain a grant for ${name}. ` +
|
|
255
|
+
`The grant may have been revoked during install; try again.`);
|
|
256
|
+
}
|
|
257
|
+
const workDir = await (0, promises_1.mkdtemp)((0, path_1.join)((0, os_1.tmpdir)(), 'donobu-plugin-'));
|
|
258
|
+
try {
|
|
259
|
+
const tarPath = (0, path_1.join)(workDir, manifest.tarball);
|
|
260
|
+
Logger_1.appLogger.info(`Downloading ${manifest.tarball}...`);
|
|
261
|
+
await downloadTarball(baseUrl, apiKey, name, version, tarPath);
|
|
262
|
+
const actualSha = await sha256OfFile(tarPath);
|
|
263
|
+
if (actualSha.toLowerCase() !== manifest.sha256.toLowerCase()) {
|
|
264
|
+
throw new Error(`Checksum mismatch for ${manifest.tarball} — refusing to install. ` +
|
|
265
|
+
`Expected ${manifest.sha256}, got ${actualSha}.`);
|
|
266
|
+
}
|
|
267
|
+
// Extract into a staging dir inside workDir first, then atomically swap
|
|
268
|
+
// into the final plugins location to avoid half-written plugin state.
|
|
269
|
+
// License file goes inside the staging dir before the swap so the
|
|
270
|
+
// post-rename plugin dir always has both bundle + license present at
|
|
271
|
+
// the same instant — no half-installed window.
|
|
272
|
+
const stageDir = (0, path_1.join)(workDir, 'stage');
|
|
273
|
+
await extractTarball(tarPath, stageDir);
|
|
274
|
+
await (0, promises_1.writeFile)((0, path_1.join)(stageDir, LICENSE_FILENAME), envelopeJson + '\n', 'utf8');
|
|
275
|
+
const parentDir = resolveInstallParentDir(into);
|
|
276
|
+
const destDir = (0, path_1.join)(parentDir, name);
|
|
277
|
+
await (0, promises_1.mkdir)(parentDir, { recursive: true });
|
|
278
|
+
// For scoped names like @donobu/donobu-mobile, ensure the @scope parent exists.
|
|
279
|
+
await (0, promises_1.mkdir)((0, path_1.join)(destDir, '..'), { recursive: true });
|
|
280
|
+
await (0, promises_1.rm)(destDir, { recursive: true, force: true });
|
|
281
|
+
await (0, promises_1.rename)(stageDir, destDir);
|
|
282
|
+
const grantExpiresAt = envelope.payload.entitlements[name].expiresAt;
|
|
283
|
+
const expiryDescription = grantExpiresAt === null ? 'perpetual (never expires)' : grantExpiresAt;
|
|
284
|
+
Logger_1.appLogger.info(`Installed ${name}@${version} to ${destDir} ` +
|
|
285
|
+
`(license colocated, entitlement ${expiryDescription}). ` +
|
|
286
|
+
`Restart Donobu to load the plugin.`);
|
|
287
|
+
// Soft-warn when the customer is installing very close to the grant's
|
|
288
|
+
// expiry — catches the "I installed today but it already stopped working"
|
|
289
|
+
// footgun.
|
|
290
|
+
if (grantExpiresAt !== null) {
|
|
291
|
+
const daysUntilExpiry = Math.floor((new Date(grantExpiresAt).getTime() - Date.now()) / 86_400_000);
|
|
292
|
+
if (daysUntilExpiry >= 0 && daysUntilExpiry < 7) {
|
|
293
|
+
Logger_1.appLogger.warn(`Your entitlement expires in ${daysUntilExpiry} day(s) ` +
|
|
294
|
+
`(${grantExpiresAt}). Contact support@donobu.com to renew before relying on this install.`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
await (0, promises_1.rm)(workDir, { recursive: true, force: true });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function parseArgs(argv) {
|
|
303
|
+
const args = argv.slice(2);
|
|
304
|
+
let fromRelease;
|
|
305
|
+
let apiKey;
|
|
306
|
+
let into;
|
|
307
|
+
for (let i = 0; i < args.length; i++) {
|
|
308
|
+
const a = args[i];
|
|
309
|
+
if (a === '--from-release' && i + 1 < args.length) {
|
|
310
|
+
fromRelease = args[++i];
|
|
311
|
+
}
|
|
312
|
+
else if (a.startsWith('--from-release=')) {
|
|
313
|
+
fromRelease = a.slice('--from-release='.length);
|
|
314
|
+
}
|
|
315
|
+
else if (a === '--api-key' && i + 1 < args.length) {
|
|
316
|
+
apiKey = args[++i];
|
|
317
|
+
}
|
|
318
|
+
else if (a.startsWith('--api-key=')) {
|
|
319
|
+
apiKey = a.slice('--api-key='.length);
|
|
320
|
+
}
|
|
321
|
+
else if (a === '--into' && i + 1 < args.length) {
|
|
322
|
+
into = args[++i];
|
|
323
|
+
}
|
|
324
|
+
else if (a.startsWith('--into=')) {
|
|
325
|
+
into = a.slice('--into='.length);
|
|
326
|
+
}
|
|
327
|
+
else if (a === '--help' || a === '-h') {
|
|
328
|
+
printUsage();
|
|
329
|
+
process.exit(0);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { fromRelease, apiKey, into };
|
|
333
|
+
}
|
|
334
|
+
function printUsage() {
|
|
335
|
+
console.log([
|
|
336
|
+
'Usage:',
|
|
337
|
+
' install-donobu-plugin # build & install from current dir',
|
|
338
|
+
' install-donobu-plugin --from-release <name>[@<version>] [--api-key <key>] [--into <dir>]',
|
|
339
|
+
'',
|
|
340
|
+
'Release-mode options:',
|
|
341
|
+
' --from-release <name>[@<version>] Install a pre-built Donobu plugin.',
|
|
342
|
+
' Omit @version to install the latest.',
|
|
343
|
+
' --api-key <key> Donobu API key (or set DONOBU_API_KEY).',
|
|
344
|
+
' Your account entitlement gates access.',
|
|
345
|
+
' --into <dir> Install the plugin under <dir>/<name>/',
|
|
346
|
+
' instead of the Donobu Studio plugins',
|
|
347
|
+
' directory. Useful for code-based test',
|
|
348
|
+
' setups — pass `./node_modules` to make',
|
|
349
|
+
' the plugin importable from your project.',
|
|
350
|
+
'',
|
|
351
|
+
'Examples:',
|
|
352
|
+
' install-donobu-plugin --from-release @donobu/donobu-mobile',
|
|
353
|
+
' install-donobu-plugin --from-release @donobu/donobu-mobile@1.0.0',
|
|
354
|
+
' install-donobu-plugin --from-release @donobu/donobu-mobile --into ./node_modules',
|
|
355
|
+
].join('\n'));
|
|
356
|
+
}
|
|
98
357
|
async function main() {
|
|
358
|
+
const { fromRelease, apiKey, into } = parseArgs(process.argv);
|
|
99
359
|
try {
|
|
100
|
-
|
|
360
|
+
if (fromRelease) {
|
|
361
|
+
await installFromRelease(parseReleaseSpec(fromRelease), getDonobuApiKey(apiKey), into);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// Local build-and-copy mode — must be run from a plugin source directory.
|
|
101
365
|
try {
|
|
102
366
|
await (0, promises_1.access)('package.json', fs_1.constants.F_OK);
|
|
103
367
|
await (0, promises_1.access)('src', fs_1.constants.F_OK);
|
|
@@ -36,8 +36,9 @@ export type PluginDependencies = {
|
|
|
36
36
|
* the primary discovery path for SDK users who `npm install` plugins
|
|
37
37
|
* alongside the `donobu` package.
|
|
38
38
|
*
|
|
39
|
-
* When a plugin is found in both sources, the
|
|
40
|
-
*
|
|
39
|
+
* When a plugin is found in both sources, the node_modules copy wins —
|
|
40
|
+
* an explicit project-level dependency should override global Studio
|
|
41
|
+
* state so tests run against the version the project declares.
|
|
41
42
|
*/
|
|
42
43
|
export declare class PluginLoader {
|
|
43
44
|
private readonly pluginsPath;
|
|
@@ -54,17 +55,42 @@ export declare class PluginLoader {
|
|
|
54
55
|
* `node_modules`. All plugin types are collected and returned.
|
|
55
56
|
*/
|
|
56
57
|
loadAllPlugins(): Promise<LoadedPlugins>;
|
|
58
|
+
/**
|
|
59
|
+
* Determine which file PluginLoader should dynamic-import for a plugin
|
|
60
|
+
* living at `pkgDir`, using a three-tier cascade:
|
|
61
|
+
*
|
|
62
|
+
* 1. `exports["./plugin"].import` — preferred for dual-use packages
|
|
63
|
+
* (those that also expose an SDK surface on the default entry).
|
|
64
|
+
* Points at a bundle built from plugin hooks only, with no
|
|
65
|
+
* value-level `donobu` imports, so it loads cleanly even from the
|
|
66
|
+
* app-data plugins dir where no sibling `donobu` module exists.
|
|
67
|
+
* Example: `@donobu/donobu-mobile`.
|
|
68
|
+
* 2. `exports.import` — the old flat shape used by plugin-only
|
|
69
|
+
* packages (no SDK surface, no value-level `donobu` imports).
|
|
70
|
+
* Examples: `tools-captcha`, `tools-mfa`, `donobu-aws`,
|
|
71
|
+
* `donobu-google`.
|
|
72
|
+
* 3. `<pkgDir>/index.mjs` — convention fallback for hand-dropped
|
|
73
|
+
* plugins that ship no `package.json` at all.
|
|
74
|
+
*
|
|
75
|
+
* `requirePackageJson` toggles whether the caller demands a
|
|
76
|
+
* package.json with `"donobu-plugin": true` at the package root
|
|
77
|
+
* (node_modules discovery) or is happy with any directory that has an
|
|
78
|
+
* `index.mjs` (app-data discovery — supports hand-dropped legacy
|
|
79
|
+
* plugins).
|
|
80
|
+
*/
|
|
81
|
+
private resolvePlugin;
|
|
57
82
|
/**
|
|
58
83
|
* Scan the app-data plugins directory for standalone plugin bundles.
|
|
59
|
-
* Each plugin is a directory containing
|
|
60
|
-
* Scoped packages (`@scope/name`) are supported
|
|
84
|
+
* Each plugin is a directory containing a `package.json` with
|
|
85
|
+
* `"donobu-plugin": true`. Scoped packages (`@scope/name`) are supported
|
|
86
|
+
* one level deep.
|
|
61
87
|
*/
|
|
62
88
|
private discoverDirectoryPlugins;
|
|
63
89
|
/**
|
|
64
90
|
* Scan `node_modules/@donobu/` for npm-installed packages that declare
|
|
65
|
-
* `"donobu-plugin": true` in their `package.json`.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
91
|
+
* `"donobu-plugin": true` in their `package.json`. Delegates the actual
|
|
92
|
+
* entry-point resolution to `resolvePlugin` so app-data and
|
|
93
|
+
* node_modules installs follow the same rules.
|
|
68
94
|
*/
|
|
69
95
|
private discoverNodeModulesPlugins;
|
|
70
96
|
}
|
|
@@ -78,6 +78,32 @@ function findNodeModulesDonobuScope() {
|
|
|
78
78
|
}
|
|
79
79
|
return undefined;
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the file PluginLoader should dynamic-import for a plugin at
|
|
83
|
+
* `pkgDir`, given its (possibly undefined) `package.json` `exports`
|
|
84
|
+
* field. Three-tier cascade — see PluginLoader.resolvePlugin for the
|
|
85
|
+
* semantic rationale for each tier.
|
|
86
|
+
*/
|
|
87
|
+
function resolvePluginEntryPath(pkgDir, exportsField) {
|
|
88
|
+
if (exportsField && typeof exportsField === 'object') {
|
|
89
|
+
const exp = exportsField;
|
|
90
|
+
// 1. Subpath export: exports["./plugin"].import
|
|
91
|
+
const subpath = exp['./plugin'];
|
|
92
|
+
if (subpath && typeof subpath === 'object') {
|
|
93
|
+
const subpathImport = subpath.import;
|
|
94
|
+
if (typeof subpathImport === 'string') {
|
|
95
|
+
return node_path_1.default.resolve(pkgDir, subpathImport);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// 2. Flat export: exports.import
|
|
99
|
+
const flatImport = exp.import;
|
|
100
|
+
if (typeof flatImport === 'string') {
|
|
101
|
+
return node_path_1.default.resolve(pkgDir, flatImport);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// 3. Convention fallback.
|
|
105
|
+
return node_path_1.default.join(pkgDir, 'index.mjs');
|
|
106
|
+
}
|
|
81
107
|
/**
|
|
82
108
|
* Loads Donobu plugins from two sources:
|
|
83
109
|
*
|
|
@@ -91,8 +117,9 @@ function findNodeModulesDonobuScope() {
|
|
|
91
117
|
* the primary discovery path for SDK users who `npm install` plugins
|
|
92
118
|
* alongside the `donobu` package.
|
|
93
119
|
*
|
|
94
|
-
* When a plugin is found in both sources, the
|
|
95
|
-
*
|
|
120
|
+
* When a plugin is found in both sources, the node_modules copy wins —
|
|
121
|
+
* an explicit project-level dependency should override global Studio
|
|
122
|
+
* state so tests run against the version the project declares.
|
|
96
123
|
*/
|
|
97
124
|
class PluginLoader {
|
|
98
125
|
constructor(pluginsPath, pluginDependencies, nodeModulesScopePath) {
|
|
@@ -116,10 +143,15 @@ class PluginLoader {
|
|
|
116
143
|
async loadAllPlugins() {
|
|
117
144
|
const dirPlugins = await this.discoverDirectoryPlugins();
|
|
118
145
|
const nmPlugins = await this.discoverNodeModulesPlugins();
|
|
119
|
-
// Deduplicate:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
146
|
+
// Deduplicate: node_modules plugins take precedence over app-data.
|
|
147
|
+
// A project that explicitly depends on a plugin version (via the
|
|
148
|
+
// install CLI's `--into ./node_modules` or a regular npm install)
|
|
149
|
+
// should get that version, not whatever global Studio state
|
|
150
|
+
// happens to carry. App-data is the fallback for plugins the project
|
|
151
|
+
// hasn't declared.
|
|
152
|
+
const seen = new Set(nmPlugins.map((p) => p.name));
|
|
153
|
+
const allPlugins = [...nmPlugins];
|
|
154
|
+
for (const p of dirPlugins) {
|
|
123
155
|
if (!seen.has(p.name)) {
|
|
124
156
|
allPlugins.push(p);
|
|
125
157
|
seen.add(p.name);
|
|
@@ -188,12 +220,59 @@ class PluginLoader {
|
|
|
188
220
|
};
|
|
189
221
|
}
|
|
190
222
|
// ---------------------------------------------------------------------------
|
|
223
|
+
// Plugin resolution
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
/**
|
|
226
|
+
* Determine which file PluginLoader should dynamic-import for a plugin
|
|
227
|
+
* living at `pkgDir`, using a three-tier cascade:
|
|
228
|
+
*
|
|
229
|
+
* 1. `exports["./plugin"].import` — preferred for dual-use packages
|
|
230
|
+
* (those that also expose an SDK surface on the default entry).
|
|
231
|
+
* Points at a bundle built from plugin hooks only, with no
|
|
232
|
+
* value-level `donobu` imports, so it loads cleanly even from the
|
|
233
|
+
* app-data plugins dir where no sibling `donobu` module exists.
|
|
234
|
+
* Example: `@donobu/donobu-mobile`.
|
|
235
|
+
* 2. `exports.import` — the old flat shape used by plugin-only
|
|
236
|
+
* packages (no SDK surface, no value-level `donobu` imports).
|
|
237
|
+
* Examples: `tools-captcha`, `tools-mfa`, `donobu-aws`,
|
|
238
|
+
* `donobu-google`.
|
|
239
|
+
* 3. `<pkgDir>/index.mjs` — convention fallback for hand-dropped
|
|
240
|
+
* plugins that ship no `package.json` at all.
|
|
241
|
+
*
|
|
242
|
+
* `requirePackageJson` toggles whether the caller demands a
|
|
243
|
+
* package.json with `"donobu-plugin": true` at the package root
|
|
244
|
+
* (node_modules discovery) or is happy with any directory that has an
|
|
245
|
+
* `index.mjs` (app-data discovery — supports hand-dropped legacy
|
|
246
|
+
* plugins).
|
|
247
|
+
*/
|
|
248
|
+
async resolvePlugin(pkgDir, requirePackageJson) {
|
|
249
|
+
let pkgJson = null;
|
|
250
|
+
try {
|
|
251
|
+
const raw = await promises_1.default.readFile(node_path_1.default.join(pkgDir, 'package.json'), 'utf-8');
|
|
252
|
+
pkgJson = JSON.parse(raw);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
if (requirePackageJson) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
// No package.json is fine for the app-data path; the dir is
|
|
259
|
+
// treated as a bare `index.mjs` plugin drop (legacy pattern).
|
|
260
|
+
}
|
|
261
|
+
if (requirePackageJson && !pkgJson?.['donobu-plugin']) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const entryPoint = resolvePluginEntryPath(pkgDir, pkgJson?.exports);
|
|
265
|
+
const name = pkgJson?.name ?? node_path_1.default.basename(pkgDir);
|
|
266
|
+
return { name, entryPoint };
|
|
267
|
+
}
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
191
269
|
// Discovery: app-data plugins directory
|
|
192
270
|
// ---------------------------------------------------------------------------
|
|
193
271
|
/**
|
|
194
272
|
* Scan the app-data plugins directory for standalone plugin bundles.
|
|
195
|
-
* Each plugin is a directory containing
|
|
196
|
-
* Scoped packages (`@scope/name`) are supported
|
|
273
|
+
* Each plugin is a directory containing a `package.json` with
|
|
274
|
+
* `"donobu-plugin": true`. Scoped packages (`@scope/name`) are supported
|
|
275
|
+
* one level deep.
|
|
197
276
|
*/
|
|
198
277
|
async discoverDirectoryPlugins() {
|
|
199
278
|
Logger_1.appLogger.info(`Scanning "${this.pluginsPath}" for plugins...`);
|
|
@@ -207,17 +286,16 @@ class PluginLoader {
|
|
|
207
286
|
const plugins = [];
|
|
208
287
|
for (const entry of dirEntries.filter((d) => d.isDirectory())) {
|
|
209
288
|
if (entry.name.startsWith('@')) {
|
|
210
|
-
// Scoped packages: scan one level deeper.
|
|
211
289
|
try {
|
|
212
290
|
const scopeDir = node_path_1.default.join(this.pluginsPath, entry.name);
|
|
213
291
|
const scopeEntries = await promises_1.default.readdir(scopeDir, {
|
|
214
292
|
withFileTypes: true,
|
|
215
293
|
});
|
|
216
294
|
for (const sub of scopeEntries.filter((d) => d.isDirectory())) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
295
|
+
const resolved = await this.resolvePlugin(node_path_1.default.join(scopeDir, sub.name), false);
|
|
296
|
+
if (resolved) {
|
|
297
|
+
plugins.push(resolved);
|
|
298
|
+
}
|
|
221
299
|
}
|
|
222
300
|
}
|
|
223
301
|
catch {
|
|
@@ -225,10 +303,10 @@ class PluginLoader {
|
|
|
225
303
|
}
|
|
226
304
|
}
|
|
227
305
|
else {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
306
|
+
const resolved = await this.resolvePlugin(node_path_1.default.join(this.pluginsPath, entry.name), false);
|
|
307
|
+
if (resolved) {
|
|
308
|
+
plugins.push(resolved);
|
|
309
|
+
}
|
|
232
310
|
}
|
|
233
311
|
}
|
|
234
312
|
return plugins;
|
|
@@ -238,9 +316,9 @@ class PluginLoader {
|
|
|
238
316
|
// ---------------------------------------------------------------------------
|
|
239
317
|
/**
|
|
240
318
|
* Scan `node_modules/@donobu/` for npm-installed packages that declare
|
|
241
|
-
* `"donobu-plugin": true` in their `package.json`.
|
|
242
|
-
*
|
|
243
|
-
*
|
|
319
|
+
* `"donobu-plugin": true` in their `package.json`. Delegates the actual
|
|
320
|
+
* entry-point resolution to `resolvePlugin` so app-data and
|
|
321
|
+
* node_modules installs follow the same rules.
|
|
244
322
|
*/
|
|
245
323
|
async discoverNodeModulesPlugins() {
|
|
246
324
|
if (!this.nodeModulesScopePath) {
|
|
@@ -258,23 +336,10 @@ class PluginLoader {
|
|
|
258
336
|
const plugins = [];
|
|
259
337
|
for (const entry of entries.filter((d) => d.isDirectory())) {
|
|
260
338
|
const pkgDir = node_path_1.default.join(this.nodeModulesScopePath, entry.name);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
// Resolve ESM entry point from package.json exports or default.
|
|
268
|
-
const esmRelative = pkgJson.exports?.import ?? 'dist/index.mjs';
|
|
269
|
-
const entryPoint = node_path_1.default.resolve(pkgDir, esmRelative);
|
|
270
|
-
plugins.push({
|
|
271
|
-
name: pkgJson.name ?? `@donobu/${entry.name}`,
|
|
272
|
-
entryPoint,
|
|
273
|
-
});
|
|
274
|
-
Logger_1.appLogger.info(`Found npm plugin "${pkgJson.name ?? entry.name}" at ${pkgDir}`);
|
|
275
|
-
}
|
|
276
|
-
catch {
|
|
277
|
-
// No package.json or unreadable — skip.
|
|
339
|
+
const resolved = await this.resolvePlugin(pkgDir, true);
|
|
340
|
+
if (resolved) {
|
|
341
|
+
plugins.push(resolved);
|
|
342
|
+
Logger_1.appLogger.info(`Found npm plugin "${resolved.name}" at ${pkgDir}`);
|
|
278
343
|
}
|
|
279
344
|
}
|
|
280
345
|
return plugins;
|
|
@@ -36,8 +36,9 @@ export type PluginDependencies = {
|
|
|
36
36
|
* the primary discovery path for SDK users who `npm install` plugins
|
|
37
37
|
* alongside the `donobu` package.
|
|
38
38
|
*
|
|
39
|
-
* When a plugin is found in both sources, the
|
|
40
|
-
*
|
|
39
|
+
* When a plugin is found in both sources, the node_modules copy wins —
|
|
40
|
+
* an explicit project-level dependency should override global Studio
|
|
41
|
+
* state so tests run against the version the project declares.
|
|
41
42
|
*/
|
|
42
43
|
export declare class PluginLoader {
|
|
43
44
|
private readonly pluginsPath;
|
|
@@ -54,17 +55,42 @@ export declare class PluginLoader {
|
|
|
54
55
|
* `node_modules`. All plugin types are collected and returned.
|
|
55
56
|
*/
|
|
56
57
|
loadAllPlugins(): Promise<LoadedPlugins>;
|
|
58
|
+
/**
|
|
59
|
+
* Determine which file PluginLoader should dynamic-import for a plugin
|
|
60
|
+
* living at `pkgDir`, using a three-tier cascade:
|
|
61
|
+
*
|
|
62
|
+
* 1. `exports["./plugin"].import` — preferred for dual-use packages
|
|
63
|
+
* (those that also expose an SDK surface on the default entry).
|
|
64
|
+
* Points at a bundle built from plugin hooks only, with no
|
|
65
|
+
* value-level `donobu` imports, so it loads cleanly even from the
|
|
66
|
+
* app-data plugins dir where no sibling `donobu` module exists.
|
|
67
|
+
* Example: `@donobu/donobu-mobile`.
|
|
68
|
+
* 2. `exports.import` — the old flat shape used by plugin-only
|
|
69
|
+
* packages (no SDK surface, no value-level `donobu` imports).
|
|
70
|
+
* Examples: `tools-captcha`, `tools-mfa`, `donobu-aws`,
|
|
71
|
+
* `donobu-google`.
|
|
72
|
+
* 3. `<pkgDir>/index.mjs` — convention fallback for hand-dropped
|
|
73
|
+
* plugins that ship no `package.json` at all.
|
|
74
|
+
*
|
|
75
|
+
* `requirePackageJson` toggles whether the caller demands a
|
|
76
|
+
* package.json with `"donobu-plugin": true` at the package root
|
|
77
|
+
* (node_modules discovery) or is happy with any directory that has an
|
|
78
|
+
* `index.mjs` (app-data discovery — supports hand-dropped legacy
|
|
79
|
+
* plugins).
|
|
80
|
+
*/
|
|
81
|
+
private resolvePlugin;
|
|
57
82
|
/**
|
|
58
83
|
* Scan the app-data plugins directory for standalone plugin bundles.
|
|
59
|
-
* Each plugin is a directory containing
|
|
60
|
-
* Scoped packages (`@scope/name`) are supported
|
|
84
|
+
* Each plugin is a directory containing a `package.json` with
|
|
85
|
+
* `"donobu-plugin": true`. Scoped packages (`@scope/name`) are supported
|
|
86
|
+
* one level deep.
|
|
61
87
|
*/
|
|
62
88
|
private discoverDirectoryPlugins;
|
|
63
89
|
/**
|
|
64
90
|
* Scan `node_modules/@donobu/` for npm-installed packages that declare
|
|
65
|
-
* `"donobu-plugin": true` in their `package.json`.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
91
|
+
* `"donobu-plugin": true` in their `package.json`. Delegates the actual
|
|
92
|
+
* entry-point resolution to `resolvePlugin` so app-data and
|
|
93
|
+
* node_modules installs follow the same rules.
|
|
68
94
|
*/
|
|
69
95
|
private discoverNodeModulesPlugins;
|
|
70
96
|
}
|
|
@@ -78,6 +78,32 @@ function findNodeModulesDonobuScope() {
|
|
|
78
78
|
}
|
|
79
79
|
return undefined;
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the file PluginLoader should dynamic-import for a plugin at
|
|
83
|
+
* `pkgDir`, given its (possibly undefined) `package.json` `exports`
|
|
84
|
+
* field. Three-tier cascade — see PluginLoader.resolvePlugin for the
|
|
85
|
+
* semantic rationale for each tier.
|
|
86
|
+
*/
|
|
87
|
+
function resolvePluginEntryPath(pkgDir, exportsField) {
|
|
88
|
+
if (exportsField && typeof exportsField === 'object') {
|
|
89
|
+
const exp = exportsField;
|
|
90
|
+
// 1. Subpath export: exports["./plugin"].import
|
|
91
|
+
const subpath = exp['./plugin'];
|
|
92
|
+
if (subpath && typeof subpath === 'object') {
|
|
93
|
+
const subpathImport = subpath.import;
|
|
94
|
+
if (typeof subpathImport === 'string') {
|
|
95
|
+
return node_path_1.default.resolve(pkgDir, subpathImport);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// 2. Flat export: exports.import
|
|
99
|
+
const flatImport = exp.import;
|
|
100
|
+
if (typeof flatImport === 'string') {
|
|
101
|
+
return node_path_1.default.resolve(pkgDir, flatImport);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// 3. Convention fallback.
|
|
105
|
+
return node_path_1.default.join(pkgDir, 'index.mjs');
|
|
106
|
+
}
|
|
81
107
|
/**
|
|
82
108
|
* Loads Donobu plugins from two sources:
|
|
83
109
|
*
|
|
@@ -91,8 +117,9 @@ function findNodeModulesDonobuScope() {
|
|
|
91
117
|
* the primary discovery path for SDK users who `npm install` plugins
|
|
92
118
|
* alongside the `donobu` package.
|
|
93
119
|
*
|
|
94
|
-
* When a plugin is found in both sources, the
|
|
95
|
-
*
|
|
120
|
+
* When a plugin is found in both sources, the node_modules copy wins —
|
|
121
|
+
* an explicit project-level dependency should override global Studio
|
|
122
|
+
* state so tests run against the version the project declares.
|
|
96
123
|
*/
|
|
97
124
|
class PluginLoader {
|
|
98
125
|
constructor(pluginsPath, pluginDependencies, nodeModulesScopePath) {
|
|
@@ -116,10 +143,15 @@ class PluginLoader {
|
|
|
116
143
|
async loadAllPlugins() {
|
|
117
144
|
const dirPlugins = await this.discoverDirectoryPlugins();
|
|
118
145
|
const nmPlugins = await this.discoverNodeModulesPlugins();
|
|
119
|
-
// Deduplicate:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
146
|
+
// Deduplicate: node_modules plugins take precedence over app-data.
|
|
147
|
+
// A project that explicitly depends on a plugin version (via the
|
|
148
|
+
// install CLI's `--into ./node_modules` or a regular npm install)
|
|
149
|
+
// should get that version, not whatever global Studio state
|
|
150
|
+
// happens to carry. App-data is the fallback for plugins the project
|
|
151
|
+
// hasn't declared.
|
|
152
|
+
const seen = new Set(nmPlugins.map((p) => p.name));
|
|
153
|
+
const allPlugins = [...nmPlugins];
|
|
154
|
+
for (const p of dirPlugins) {
|
|
123
155
|
if (!seen.has(p.name)) {
|
|
124
156
|
allPlugins.push(p);
|
|
125
157
|
seen.add(p.name);
|
|
@@ -188,12 +220,59 @@ class PluginLoader {
|
|
|
188
220
|
};
|
|
189
221
|
}
|
|
190
222
|
// ---------------------------------------------------------------------------
|
|
223
|
+
// Plugin resolution
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
/**
|
|
226
|
+
* Determine which file PluginLoader should dynamic-import for a plugin
|
|
227
|
+
* living at `pkgDir`, using a three-tier cascade:
|
|
228
|
+
*
|
|
229
|
+
* 1. `exports["./plugin"].import` — preferred for dual-use packages
|
|
230
|
+
* (those that also expose an SDK surface on the default entry).
|
|
231
|
+
* Points at a bundle built from plugin hooks only, with no
|
|
232
|
+
* value-level `donobu` imports, so it loads cleanly even from the
|
|
233
|
+
* app-data plugins dir where no sibling `donobu` module exists.
|
|
234
|
+
* Example: `@donobu/donobu-mobile`.
|
|
235
|
+
* 2. `exports.import` — the old flat shape used by plugin-only
|
|
236
|
+
* packages (no SDK surface, no value-level `donobu` imports).
|
|
237
|
+
* Examples: `tools-captcha`, `tools-mfa`, `donobu-aws`,
|
|
238
|
+
* `donobu-google`.
|
|
239
|
+
* 3. `<pkgDir>/index.mjs` — convention fallback for hand-dropped
|
|
240
|
+
* plugins that ship no `package.json` at all.
|
|
241
|
+
*
|
|
242
|
+
* `requirePackageJson` toggles whether the caller demands a
|
|
243
|
+
* package.json with `"donobu-plugin": true` at the package root
|
|
244
|
+
* (node_modules discovery) or is happy with any directory that has an
|
|
245
|
+
* `index.mjs` (app-data discovery — supports hand-dropped legacy
|
|
246
|
+
* plugins).
|
|
247
|
+
*/
|
|
248
|
+
async resolvePlugin(pkgDir, requirePackageJson) {
|
|
249
|
+
let pkgJson = null;
|
|
250
|
+
try {
|
|
251
|
+
const raw = await promises_1.default.readFile(node_path_1.default.join(pkgDir, 'package.json'), 'utf-8');
|
|
252
|
+
pkgJson = JSON.parse(raw);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
if (requirePackageJson) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
// No package.json is fine for the app-data path; the dir is
|
|
259
|
+
// treated as a bare `index.mjs` plugin drop (legacy pattern).
|
|
260
|
+
}
|
|
261
|
+
if (requirePackageJson && !pkgJson?.['donobu-plugin']) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const entryPoint = resolvePluginEntryPath(pkgDir, pkgJson?.exports);
|
|
265
|
+
const name = pkgJson?.name ?? node_path_1.default.basename(pkgDir);
|
|
266
|
+
return { name, entryPoint };
|
|
267
|
+
}
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
191
269
|
// Discovery: app-data plugins directory
|
|
192
270
|
// ---------------------------------------------------------------------------
|
|
193
271
|
/**
|
|
194
272
|
* Scan the app-data plugins directory for standalone plugin bundles.
|
|
195
|
-
* Each plugin is a directory containing
|
|
196
|
-
* Scoped packages (`@scope/name`) are supported
|
|
273
|
+
* Each plugin is a directory containing a `package.json` with
|
|
274
|
+
* `"donobu-plugin": true`. Scoped packages (`@scope/name`) are supported
|
|
275
|
+
* one level deep.
|
|
197
276
|
*/
|
|
198
277
|
async discoverDirectoryPlugins() {
|
|
199
278
|
Logger_1.appLogger.info(`Scanning "${this.pluginsPath}" for plugins...`);
|
|
@@ -207,17 +286,16 @@ class PluginLoader {
|
|
|
207
286
|
const plugins = [];
|
|
208
287
|
for (const entry of dirEntries.filter((d) => d.isDirectory())) {
|
|
209
288
|
if (entry.name.startsWith('@')) {
|
|
210
|
-
// Scoped packages: scan one level deeper.
|
|
211
289
|
try {
|
|
212
290
|
const scopeDir = node_path_1.default.join(this.pluginsPath, entry.name);
|
|
213
291
|
const scopeEntries = await promises_1.default.readdir(scopeDir, {
|
|
214
292
|
withFileTypes: true,
|
|
215
293
|
});
|
|
216
294
|
for (const sub of scopeEntries.filter((d) => d.isDirectory())) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
295
|
+
const resolved = await this.resolvePlugin(node_path_1.default.join(scopeDir, sub.name), false);
|
|
296
|
+
if (resolved) {
|
|
297
|
+
plugins.push(resolved);
|
|
298
|
+
}
|
|
221
299
|
}
|
|
222
300
|
}
|
|
223
301
|
catch {
|
|
@@ -225,10 +303,10 @@ class PluginLoader {
|
|
|
225
303
|
}
|
|
226
304
|
}
|
|
227
305
|
else {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
306
|
+
const resolved = await this.resolvePlugin(node_path_1.default.join(this.pluginsPath, entry.name), false);
|
|
307
|
+
if (resolved) {
|
|
308
|
+
plugins.push(resolved);
|
|
309
|
+
}
|
|
232
310
|
}
|
|
233
311
|
}
|
|
234
312
|
return plugins;
|
|
@@ -238,9 +316,9 @@ class PluginLoader {
|
|
|
238
316
|
// ---------------------------------------------------------------------------
|
|
239
317
|
/**
|
|
240
318
|
* Scan `node_modules/@donobu/` for npm-installed packages that declare
|
|
241
|
-
* `"donobu-plugin": true` in their `package.json`.
|
|
242
|
-
*
|
|
243
|
-
*
|
|
319
|
+
* `"donobu-plugin": true` in their `package.json`. Delegates the actual
|
|
320
|
+
* entry-point resolution to `resolvePlugin` so app-data and
|
|
321
|
+
* node_modules installs follow the same rules.
|
|
244
322
|
*/
|
|
245
323
|
async discoverNodeModulesPlugins() {
|
|
246
324
|
if (!this.nodeModulesScopePath) {
|
|
@@ -258,23 +336,10 @@ class PluginLoader {
|
|
|
258
336
|
const plugins = [];
|
|
259
337
|
for (const entry of entries.filter((d) => d.isDirectory())) {
|
|
260
338
|
const pkgDir = node_path_1.default.join(this.nodeModulesScopePath, entry.name);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
// Resolve ESM entry point from package.json exports or default.
|
|
268
|
-
const esmRelative = pkgJson.exports?.import ?? 'dist/index.mjs';
|
|
269
|
-
const entryPoint = node_path_1.default.resolve(pkgDir, esmRelative);
|
|
270
|
-
plugins.push({
|
|
271
|
-
name: pkgJson.name ?? `@donobu/${entry.name}`,
|
|
272
|
-
entryPoint,
|
|
273
|
-
});
|
|
274
|
-
Logger_1.appLogger.info(`Found npm plugin "${pkgJson.name ?? entry.name}" at ${pkgDir}`);
|
|
275
|
-
}
|
|
276
|
-
catch {
|
|
277
|
-
// No package.json or unreadable — skip.
|
|
339
|
+
const resolved = await this.resolvePlugin(pkgDir, true);
|
|
340
|
+
if (resolved) {
|
|
341
|
+
plugins.push(resolved);
|
|
342
|
+
Logger_1.appLogger.info(`Found npm plugin "${resolved.name}" at ${pkgDir}`);
|
|
278
343
|
}
|
|
279
344
|
}
|
|
280
345
|
return plugins;
|