@saltcorn/copilot 0.7.5 → 0.8.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/actions/generate-js-action.js +43 -5
- package/actions/generate-tables.js +281 -96
- package/actions/generate-trigger.js +61 -0
- package/actions/generate-workflow.js +89 -37
- package/actions/install-plugin-action.js +103 -0
- package/agent-skills/database-design.js +139 -87
- package/agent-skills/install-plugin.js +111 -0
- package/agent-skills/js-action.js +183 -0
- package/agent-skills/pagegen.js +19 -6
- package/agent-skills/registry-editor.js +911 -0
- package/agent-skills/triggergen.js +263 -0
- package/agent-skills/viewgen.js +431 -29
- package/agent-skills/workflow.js +52 -2
- package/app-constructor/common.js +12 -0
- package/app-constructor/errors.js +102 -0
- package/app-constructor/feedback-action.js +175 -0
- package/app-constructor/feedback.js +112 -0
- package/app-constructor/progress.js +116 -0
- package/app-constructor/prompts.js +120 -0
- package/app-constructor/requirements.js +156 -0
- package/app-constructor/run_task.js +146 -0
- package/app-constructor/schema.js +199 -0
- package/app-constructor/taskchart.js +70 -0
- package/app-constructor/tasks.js +585 -0
- package/app-constructor/tools.js +81 -0
- package/app-constructor/view.js +209 -0
- package/builder-gen.js +590 -68
- package/builder-schema.js +26 -6
- package/chat-copilot.js +1 -0
- package/common.js +20 -0
- package/copilot-as-agent.js +7 -1
- package/index.js +23 -1
- package/js-code-gen.js +65 -0
- package/package.json +1 -1
- package/relation-paths.js +236 -0
- package/tests/builder-gen.test.js +56 -0
|
@@ -14,7 +14,7 @@ const optionNames = (options = []) =>
|
|
|
14
14
|
.map((opt) =>
|
|
15
15
|
typeof opt === "string"
|
|
16
16
|
? opt
|
|
17
|
-
: opt?.name || opt?.label || opt?.value || ""
|
|
17
|
+
: opt?.name || opt?.label || opt?.value || ""
|
|
18
18
|
)
|
|
19
19
|
.filter(Boolean);
|
|
20
20
|
|
|
@@ -39,8 +39,12 @@ const summarizeTriggerActionMatrix = () => {
|
|
|
39
39
|
const pool = table_triggers.includes(when) ? allActions : noRowActions;
|
|
40
40
|
return `* ${when}: ${joinOptionNames(pool)}`;
|
|
41
41
|
});
|
|
42
|
-
const notes = `Triggers requiring a table context: ${table_triggers.join(
|
|
43
|
-
|
|
42
|
+
const notes = `Triggers requiring a table context: ${table_triggers.join(
|
|
43
|
+
", "
|
|
44
|
+
)}.
|
|
45
|
+
Additional triggers that commonly use contextual only_if checks: ${additional_triggers_with_onlyif.join(
|
|
46
|
+
", "
|
|
47
|
+
)}.`;
|
|
44
48
|
return `${notes}
|
|
45
49
|
${lines.join("\n")}`;
|
|
46
50
|
} catch (e) {
|
|
@@ -58,8 +62,10 @@ const summarizeTables = async () => {
|
|
|
58
62
|
const rows = tables.map((table) => {
|
|
59
63
|
const description = table.description ? ` – ${table.description}` : "";
|
|
60
64
|
const fields = (table.fields || [])
|
|
61
|
-
.
|
|
62
|
-
|
|
65
|
+
.map((f) => {
|
|
66
|
+
const tag = f.calculated && !f.stored ? " (virtual, read-only)" : "";
|
|
67
|
+
return `${f.name}:${fieldType(f)}${tag}`;
|
|
68
|
+
})
|
|
63
69
|
.join(", ");
|
|
64
70
|
const fieldText = fields ? ` fields: ${fields}` : "";
|
|
65
71
|
return `* ${table.name}${description}${fieldText}`;
|
|
@@ -77,7 +83,7 @@ const steps = async () => {
|
|
|
77
83
|
|
|
78
84
|
let stateActions = getState().actions;
|
|
79
85
|
const stateActionList = Object.entries(stateActions).filter(
|
|
80
|
-
([k, v]) => !v.disableInWorkflow
|
|
86
|
+
([k, v]) => !v.disableInWorkflow
|
|
81
87
|
);
|
|
82
88
|
|
|
83
89
|
const stepTypeAndCfg = Object.keys(actionExplainers).map((actionName) => {
|
|
@@ -85,7 +91,7 @@ const steps = async () => {
|
|
|
85
91
|
step_type: { type: "string", enum: [actionName] },
|
|
86
92
|
};
|
|
87
93
|
const myFields = actionFields.filter(
|
|
88
|
-
(f) => f.showIf?.wf_action_name === actionName
|
|
94
|
+
(f) => f.showIf?.wf_action_name === actionName
|
|
89
95
|
);
|
|
90
96
|
const required = ["step_type"];
|
|
91
97
|
myFields.forEach((f) => {
|
|
@@ -147,14 +153,15 @@ const steps = async () => {
|
|
|
147
153
|
const table = Table.findOne({ id: trigger.table_id });
|
|
148
154
|
const fieldSpecs = [];
|
|
149
155
|
table.fields.forEach((f) => {
|
|
156
|
+
if (f.calculated && !f.stored) return; // virtual fields have no DB column
|
|
150
157
|
// TODO fkeys dereferenced.
|
|
151
158
|
fieldSpecs.push(`${f.name} with ${f.pretty_type} type`);
|
|
152
159
|
});
|
|
153
160
|
properties.row_expr = {
|
|
154
161
|
type: "string",
|
|
155
162
|
description: `JavaScript expression for the input to the action. This should be an expression for an object, with the following field name and types: ${fieldSpecs.join(
|
|
156
|
-
"; "
|
|
157
|
-
)}.`,
|
|
163
|
+
"; "
|
|
164
|
+
)}. IMPORTANT: omit any non-stored calculated fields — they have no database column and cannot be written. Only include regular fields and stored calculated fields. Keep this expression simple: it should reference values already in the context, not perform inline queries or complex logic. If the values are not yet in context, add a dedicated preceding step (TableQuery, run_js_code, or whichever fits) to fetch or compute them first.`,
|
|
158
165
|
};
|
|
159
166
|
}
|
|
160
167
|
const required = ["step_type"];
|
|
@@ -208,7 +215,8 @@ class GenerateWorkflow {
|
|
|
208
215
|
workflow_steps: await steps(),
|
|
209
216
|
workflow_name: {
|
|
210
217
|
description:
|
|
211
|
-
"The name of the workflow. Can include spaces and mixed case, should be 1-5 words."
|
|
218
|
+
"The name of the workflow. Can include spaces and mixed case, should be 1-5 words. " +
|
|
219
|
+
"Must be unique across all workflows. When creating variants for different events on the same table (e.g. insert, update, delete), include the event in each name — e.g. 'Recalc trip packing insert', 'Recalc trip packing update'.",
|
|
212
220
|
type: "string",
|
|
213
221
|
},
|
|
214
222
|
when_trigger: {
|
|
@@ -218,7 +226,9 @@ class GenerateWorkflow {
|
|
|
218
226
|
enum: Trigger.when_options,
|
|
219
227
|
},
|
|
220
228
|
trigger_table: {
|
|
221
|
-
description: `If the workflow trigger is ${table_triggers.join(
|
|
229
|
+
description: `If the workflow trigger is ${table_triggers.join(
|
|
230
|
+
", "
|
|
231
|
+
)}, the name of the table that triggers the workflow`,
|
|
222
232
|
type: "string",
|
|
223
233
|
},
|
|
224
234
|
},
|
|
@@ -229,7 +239,7 @@ class GenerateWorkflow {
|
|
|
229
239
|
const actionExplainers = WorkflowStep.builtInActionExplainers();
|
|
230
240
|
let stateActions = getState().actions;
|
|
231
241
|
const stateActionList = Object.entries(stateActions).filter(
|
|
232
|
-
([k, v]) => !v.disableInWorkflow
|
|
242
|
+
([k, v]) => !v.disableInWorkflow
|
|
233
243
|
);
|
|
234
244
|
const [tableSummary, triggerMatrix] = await Promise.all([
|
|
235
245
|
summarizeTables(),
|
|
@@ -242,30 +252,46 @@ class GenerateWorkflow {
|
|
|
242
252
|
.filter(Boolean)
|
|
243
253
|
.join("\n\n");
|
|
244
254
|
|
|
245
|
-
return `Use the generate_workflow tool to construct computational workflows according to specifications. You must create
|
|
255
|
+
return `Use the generate_workflow tool to construct computational workflows according to specifications. You must create
|
|
246
256
|
the workflow by calling the generate_workflow tool, with the step required to implement the specification.
|
|
247
|
-
|
|
257
|
+
|
|
258
|
+
**Trigger vs workflow:** If the task can be completed in a single step (e.g. updating one field with modify_row, sending a single notification), use the generate_trigger tool instead — a workflow is only appropriate when multiple steps, branching, or looping are required. If the task requires several independent single-step actions (e.g. "mark complete" and "mark incomplete"), create a separate trigger for each — do NOT bundle them into one workflow.
|
|
259
|
+
|
|
260
|
+
**Naming:** Workflow names must be unique. When creating variants for different events on the same table (e.g. insert, update, delete), include the event in each name — e.g. "Recalc trip packing insert", "Recalc trip packing update", "Recalc trip packing delete".
|
|
261
|
+
|
|
248
262
|
${contextBlocks}
|
|
249
|
-
|
|
250
|
-
The steps are specified as JSON objects. Each step has a name, specified in the step_name key in the JSON object.
|
|
263
|
+
|
|
264
|
+
The steps are specified as JSON objects. Each step has a name, specified in the step_name key in the JSON object.
|
|
251
265
|
The step name should be a valid JavaScript identifier.
|
|
252
|
-
|
|
266
|
+
|
|
253
267
|
When the user explicitly names an action/step type (for example "ForLoop" or "Toast"), ensure the generated workflow contains at least one step whose step_configuration.step_type exactly matches every requested action. Only skip this if the action genuinely does not exist; in that case, clearly explain the omission in the workflow description.
|
|
254
268
|
|
|
269
|
+
CRITICAL — non-stored calculated fields cannot be written:
|
|
270
|
+
In the table summary above, fields marked (virtual, read-only) are non-stored calculated fields — they have no database column and are computed on-the-fly by Saltcorn. Never include such fields in modify_row row expressions, run_js_code return objects, or SQL UPDATE statements. If you see a field in the table summary marked (virtual, read-only), treat it as read-only everywhere: omit it from any write operation. Only regular fields and stored calculated fields (not marked virtual) have database columns and can be written. Virtual fields refresh automatically when the fields they depend on change — no explicit update is needed.
|
|
271
|
+
|
|
272
|
+
IMPORTANT — keep row expressions simple; use dedicated steps for data fetching:
|
|
273
|
+
Row expressions (e.g. in modify_row) should be simple references to values already in the context — not inline queries or complex logic. If the values you need are not already in context, add a dedicated step before the row expression step to fetch or compute them and write the results into the context. Choose the step type that fits the job: TableQuery to query a table, run_js_code for custom computation, or any other appropriate step. Each step should do one clear thing; the row expression then just picks the relevant context values.
|
|
274
|
+
|
|
275
|
+
CRITICAL — every workflow must form a single connected chain from the first step to the last:
|
|
276
|
+
- Every step except the very last one MUST have a next_step that names another step in the workflow.
|
|
277
|
+
- The very last step must have next_step omitted or set to an empty string to terminate the workflow.
|
|
278
|
+
- No step may be an island: every step must be reachable by following next_step links from the first step.
|
|
279
|
+
- Before submitting, mentally trace the path: first_step → next_step → … → last_step. If any step is unreachable or any link is missing, fix it before calling the tool.
|
|
280
|
+
|
|
255
281
|
Each run of the workflow is executed in the presence of a context, which is a JavaScript object that individual
|
|
256
|
-
steps can read values from and write values to. This context is a state that is persisted on disk for each workflow
|
|
257
|
-
run.
|
|
258
|
-
|
|
259
|
-
Each step can have a next_step key which is the name of the next step, or a JavaScript expression which evaluates
|
|
260
|
-
to the name of the next step based on the context. In the evaluation of the next step, each value in the context is
|
|
261
|
-
in scope and can be addressed directly. Identifiers for the step names are also in scope, the name of the next step
|
|
262
|
-
can be used directly without enclosing it in quotes to form a string.
|
|
263
|
-
|
|
282
|
+
steps can read values from and write values to. This context is a state that is persisted on disk for each workflow
|
|
283
|
+
run.
|
|
284
|
+
|
|
285
|
+
Each step can have a next_step key which is the name of the next step, or a JavaScript expression which evaluates
|
|
286
|
+
to the name of the next step based on the context. In the evaluation of the next step, each value in the context is
|
|
287
|
+
in scope and can be addressed directly. Identifiers for the step names are also in scope, the name of the next step
|
|
288
|
+
can be used directly without enclosing it in quotes to form a string.
|
|
289
|
+
|
|
264
290
|
For example, if the context contains a value x which is an integer and you have steps named "too_low" and "too_high",
|
|
265
291
|
and you would like the next step to be too_low if x is less than 10 and too_high otherwise,
|
|
266
292
|
use this as the next_step expression: x<10 ? too_low : too_high
|
|
267
|
-
|
|
268
|
-
If the next_step is omitted then the workflow terminates.
|
|
293
|
+
|
|
294
|
+
If the next_step is omitted then the workflow terminates. Only the final step should have next_step omitted.
|
|
269
295
|
|
|
270
296
|
Each step has a step_configuration object which contains the step type and the specific parameters of
|
|
271
297
|
that step type. You should specify the step type in the step_type subfield of the step_configuration
|
|
@@ -282,9 +308,11 @@ class GenerateWorkflow {
|
|
|
282
308
|
step types:
|
|
283
309
|
|
|
284
310
|
run_js_code: if the step_type is "run_js_code" then the step object should include the JavaScript code to be executed in the "code"
|
|
285
|
-
key. You can use await in the code if you need to run asynchronous code. The values in the context are directly in scope and can be accessed using their name. In addition, the variable
|
|
311
|
+
key. You can use await in the code if you need to run asynchronous code. The values in the context are directly in scope and can be accessed using their name. In addition, the variable
|
|
286
312
|
"context" is also in scope and can be used to address the context as a whole. To write values to the context, return an
|
|
287
|
-
object. The
|
|
313
|
+
object. The following Saltcorn models are already available in scope without any require: Table, Row, Field, User, View, Trigger, Page, File — use them directly.
|
|
314
|
+
When you need to require other modules, always use a plain require call, e.g. const moment = require('moment');
|
|
315
|
+
NEVER use the pattern const X = X || require(...) — this causes a ReferenceError because const variables cannot be referenced before initialization. The values in this object will be written into the current context. If a value already exists in the context
|
|
288
316
|
it will be overwritten. For example, If the context contains values x and y which are numbers and you would like to push
|
|
289
317
|
the value "sum" which is the sum of x and y, then use this as the code: return {sum: x+y}. You cannot set the next step in the
|
|
290
318
|
return object or by returning a string from a run_js_code step, this will not work. To set the next step from a code action, always use the next_step property of the step object.
|
|
@@ -318,8 +346,25 @@ class GenerateWorkflow {
|
|
|
318
346
|
static async execute(
|
|
319
347
|
{ workflow_steps, workflow_name, when_trigger, trigger_table },
|
|
320
348
|
req,
|
|
349
|
+
context_vars
|
|
321
350
|
) {
|
|
322
|
-
|
|
351
|
+
let allSteps = workflow_steps;
|
|
352
|
+
let initContextStep = null;
|
|
353
|
+
if (context_vars && Object.keys(context_vars).length) {
|
|
354
|
+
const firstUserStep = allSteps[0]?.step_name || "";
|
|
355
|
+
initContextStep = {
|
|
356
|
+
step_name: "init_context",
|
|
357
|
+
only_if: "",
|
|
358
|
+
next_step: firstUserStep,
|
|
359
|
+
step_configuration: {
|
|
360
|
+
step_type: "run_js_code",
|
|
361
|
+
run_where: "Server",
|
|
362
|
+
code: `return ${JSON.stringify(context_vars, null, 2)};`,
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
allSteps = [initContextStep, ...allSteps];
|
|
366
|
+
}
|
|
367
|
+
const steps = this.process_all_steps(allSteps);
|
|
323
368
|
let table_id;
|
|
324
369
|
if (trigger_table) {
|
|
325
370
|
const table = Table.findOne({ name: trigger_table });
|
|
@@ -333,10 +378,17 @@ class GenerateWorkflow {
|
|
|
333
378
|
action: "Workflow",
|
|
334
379
|
configuration: {},
|
|
335
380
|
});
|
|
336
|
-
|
|
381
|
+
// Insert user steps first so the init_context next_step target already
|
|
382
|
+
// exists in the DB when init_context is created.
|
|
383
|
+
const [initStep, ...userSteps] = initContextStep ? steps : [null, ...steps];
|
|
384
|
+
for (const step of initContextStep ? userSteps : steps) {
|
|
337
385
|
step.trigger_id = trigger.id;
|
|
338
386
|
await WorkflowStep.create(step);
|
|
339
387
|
}
|
|
388
|
+
if (initStep) {
|
|
389
|
+
initStep.trigger_id = trigger.id;
|
|
390
|
+
await WorkflowStep.create(initStep);
|
|
391
|
+
}
|
|
340
392
|
Trigger.emitEvent("AppChange", `Trigger ${trigger.name}`, req?.user, {
|
|
341
393
|
entity_type: "Trigger",
|
|
342
394
|
entity_name: trigger.name,
|
|
@@ -346,7 +398,7 @@ class GenerateWorkflow {
|
|
|
346
398
|
"Workflow created. " +
|
|
347
399
|
a(
|
|
348
400
|
{ target: "_blank", href: `/actions/configure/${trigger.id}` },
|
|
349
|
-
"Configure workflow."
|
|
401
|
+
"Configure workflow."
|
|
350
402
|
),
|
|
351
403
|
};
|
|
352
404
|
}
|
|
@@ -364,14 +416,14 @@ class GenerateWorkflow {
|
|
|
364
416
|
step.id = ix + 1;
|
|
365
417
|
});
|
|
366
418
|
const mmdia = WorkflowStep.generate_diagram(
|
|
367
|
-
steps.map((s) => new WorkflowStep(s))
|
|
419
|
+
steps.map((s) => new WorkflowStep(s))
|
|
368
420
|
);
|
|
369
421
|
console.log({ mmdia });
|
|
370
422
|
return (
|
|
371
423
|
div(
|
|
372
424
|
`${workflow_name}${when_trigger ? `: ${when_trigger}` : ""}${
|
|
373
425
|
trigger_table ? ` on ${trigger_table}` : ""
|
|
374
|
-
}
|
|
426
|
+
}`
|
|
375
427
|
) +
|
|
376
428
|
pre({ class: "mermaid" }, mmdia) +
|
|
377
429
|
script(
|
|
@@ -380,13 +432,13 @@ class GenerateWorkflow {
|
|
|
380
432
|
mermaid.initialize({ startOnLoad: false });
|
|
381
433
|
mermaid.run({ querySelector: ".mermaid" });
|
|
382
434
|
});
|
|
383
|
-
`)
|
|
384
|
-
)
|
|
435
|
+
`)
|
|
436
|
+
)
|
|
385
437
|
);
|
|
386
438
|
}
|
|
387
439
|
|
|
388
440
|
return `A workflow! Step names: ${workflow_steps.map(
|
|
389
|
-
(s) => s.step_name
|
|
441
|
+
(s) => s.step_name
|
|
390
442
|
)}. Upgrade Saltcorn to see diagrams in copilot`;
|
|
391
443
|
}
|
|
392
444
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Core install logic. Deprecated chat-copilot format;
|
|
2
|
+
// the Agent Chat structure uses agent-skills/install-plugin.js instead.
|
|
3
|
+
const { getState } = require("@saltcorn/data/db/state");
|
|
4
|
+
const db = require("@saltcorn/data/db");
|
|
5
|
+
const Plugin = require("@saltcorn/data/models/plugin");
|
|
6
|
+
const { div, span, a } = require("@saltcorn/markup/tags");
|
|
7
|
+
|
|
8
|
+
class InstallPluginAction {
|
|
9
|
+
static title = "Install Plugin";
|
|
10
|
+
static function_name = "install_plugin";
|
|
11
|
+
static description = "Install a Saltcorn plugin from the store or from npm";
|
|
12
|
+
|
|
13
|
+
static json_schema() {
|
|
14
|
+
return {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
plugin_name: {
|
|
18
|
+
description:
|
|
19
|
+
"Name of the plugin as it appears in the Saltcorn plugin store (e.g. 'maps', 'chart'). Use this when the user refers to a plugin by its friendly name.",
|
|
20
|
+
type: "string",
|
|
21
|
+
},
|
|
22
|
+
npm_package: {
|
|
23
|
+
description:
|
|
24
|
+
"NPM package name to install directly (e.g. '@saltcorn/fullcalendar'). Use this when the user specifies an npm package name.",
|
|
25
|
+
type: "string",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static async system_prompt() {
|
|
32
|
+
return (
|
|
33
|
+
`Use the install_plugin function to install a Saltcorn plugin when the user asks to install, add, or enable a plugin. ` +
|
|
34
|
+
`Prefer plugin_name (store lookup) when the user gives a human-readable name. ` +
|
|
35
|
+
`Use npm_package when the user supplies an npm package name. ` +
|
|
36
|
+
`Do not install the same plugin twice; check if it is already installed first.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static render_html({ plugin_name, npm_package }) {
|
|
41
|
+
const label = plugin_name || npm_package;
|
|
42
|
+
return div(
|
|
43
|
+
{ class: "mb-2" },
|
|
44
|
+
span({ class: "badge bg-secondary me-2" }, "Plugin"),
|
|
45
|
+
span({ class: "fw-bold" }, label)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static async execute({ plugin_name, npm_package }, req) {
|
|
50
|
+
const schema = db.getTenantSchema();
|
|
51
|
+
|
|
52
|
+
// Resolve the plugin object
|
|
53
|
+
let plugin;
|
|
54
|
+
if (plugin_name) {
|
|
55
|
+
plugin = await Plugin.store_by_name(plugin_name);
|
|
56
|
+
if (!plugin) {
|
|
57
|
+
return { postExec: `Plugin "${plugin_name}" not found in store.` };
|
|
58
|
+
}
|
|
59
|
+
// strip any existing DB id so we insert fresh
|
|
60
|
+
delete plugin.id;
|
|
61
|
+
} else if (npm_package) {
|
|
62
|
+
plugin = new Plugin({
|
|
63
|
+
name: npm_package,
|
|
64
|
+
source: "npm",
|
|
65
|
+
location: npm_package,
|
|
66
|
+
});
|
|
67
|
+
} else {
|
|
68
|
+
return { postExec: "Please provide a plugin name or npm package." };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check already installed
|
|
72
|
+
const existing = await Plugin.findOne({ name: plugin.name });
|
|
73
|
+
if (existing) {
|
|
74
|
+
return {
|
|
75
|
+
postExec:
|
|
76
|
+
`Plugin "${plugin.name}" is already installed. ` +
|
|
77
|
+
a({ target: "_blank", href: `/plugins` }, "Manage plugins."),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const force = schema === db.connectObj.default_schema;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const msgs = await Plugin.loadAndSaveNewPlugin(
|
|
85
|
+
plugin,
|
|
86
|
+
force,
|
|
87
|
+
undefined,
|
|
88
|
+
(s) => s,
|
|
89
|
+
true // allowUnsafeOnTenantsWithoutConfigSetting
|
|
90
|
+
);
|
|
91
|
+
const warnings = (msgs || []).map((m) => `<br>⚠ ${m}`).join("");
|
|
92
|
+
return {
|
|
93
|
+
postExec:
|
|
94
|
+
`Plugin "${plugin.name}" installed successfully.${warnings} ` +
|
|
95
|
+
a({ target: "_blank", href: `/plugins` }, "Manage plugins."),
|
|
96
|
+
};
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return { postExec: `Error installing plugin: ${e.message}` };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = InstallPluginAction;
|