@visualautomate/cli 0.5.1 → 0.6.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/README.md +61 -16
- package/dist/index.js +492 -299
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
# @visualautomate/cli
|
|
2
2
|
|
|
3
|
-
Build and
|
|
3
|
+
Build and test VisualAutomate plugins.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install -g @visualautomate/cli
|
|
7
|
-
vsa login
|
|
8
7
|
vsa init my-step
|
|
9
8
|
cd my-step
|
|
10
9
|
vsa dev # the sandbox, and a run again on every save
|
|
11
|
-
vsa push
|
|
12
10
|
```
|
|
13
11
|
|
|
12
|
+
Nothing here signs in. Your plugin reaches the platform through its GitHub
|
|
13
|
+
repository: push a commit, the tests run there, and Publish on the portal takes
|
|
14
|
+
it from the commit that passed.
|
|
15
|
+
|
|
14
16
|
`va` and `visualautomate` are the same command.
|
|
15
17
|
|
|
16
18
|
That is the whole toolchain. There is nothing else to install: the CLI carries
|
|
@@ -33,6 +35,52 @@ tsconfig.json so your editor type-checks it
|
|
|
33
35
|
Templates: `blank`, `http-request`, `connection`, `file`. Pass one with
|
|
34
36
|
`--template`. Each runs unmodified.
|
|
35
37
|
|
|
38
|
+
## Types in your editor
|
|
39
|
+
|
|
40
|
+
Your plugin's code imports nothing — the platform refuses the word `import`
|
|
41
|
+
anywhere in the file, comments included, and the SDK is not a package a step may
|
|
42
|
+
require. So the types are written into your project instead, and one line names
|
|
43
|
+
them:
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
/** @type {PluginModule} */
|
|
47
|
+
module.exports = {
|
|
48
|
+
execute: async (input, config, context) => {
|
|
49
|
+
// ^ completes, with the documentation
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`dev` and `test` write `visualautomate.d.ts` and a `jsconfig.json` beside your
|
|
55
|
+
code and keep them current; `vsa types` does it on its own. In an app's
|
|
56
|
+
repository they go at the root, where they cover every module. Nothing is
|
|
57
|
+
installed, nothing of it exists while your plugin runs, and a file you wrote
|
|
58
|
+
yourself is never written over — delete ours and it comes back.
|
|
59
|
+
|
|
60
|
+
## `add:property`
|
|
61
|
+
|
|
62
|
+
A field on the node — what somebody fills in when they drop your module into a
|
|
63
|
+
workflow — written into `manifest.json`:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
vsa add:property string apiKey "The key to call with"
|
|
67
|
+
vsa add:property number retries "How many times to try again" 2 --required
|
|
68
|
+
vsa add:property select mode "Which way round" fast --options "Fast:fast,Thorough:thorough"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`<type> <name> [description] [default]`, where the name is what your code reads:
|
|
72
|
+
`config.apiKey`. The label is made from the name unless `--label` says otherwise,
|
|
73
|
+
and the default is stored as its type has it, so `2` is a number and `false` is a
|
|
74
|
+
boolean.
|
|
75
|
+
|
|
76
|
+
Types: `string`, `number`, `boolean`, `select`, `multiselect`, `json`, `color`,
|
|
77
|
+
`date`, `connection`, `file`, `path`, `paths`. A `select` or `multiselect` needs
|
|
78
|
+
`--options`, and a `connection` needs `--provider <name>`.
|
|
79
|
+
|
|
80
|
+
At the root of an app's repository, `--module <name>` says which one — the same
|
|
81
|
+
short names `test` and `dev` take. The manifest is edited where it stands: your
|
|
82
|
+
comments, the examples underneath and your own formatting stay as they are.
|
|
83
|
+
|
|
36
84
|
## `test`
|
|
37
85
|
|
|
38
86
|
Runs your plugin the way the platform would, in three steps:
|
|
@@ -59,6 +107,10 @@ state {"runs":1}
|
|
|
59
107
|
A run passes when it succeeds and, if the test file names an `expectOutput`,
|
|
60
108
|
leaves by that port — the same verdict as the GitHub check.
|
|
61
109
|
|
|
110
|
+
The verdict is green when it passed and red when it did not, in the sandbox as
|
|
111
|
+
well as out here. `NO_COLOR` turns that off, and output that is not a terminal —
|
|
112
|
+
a pipe, a file, a CI log — is plain anyway; `FORCE_COLOR` turns it back on.
|
|
113
|
+
|
|
62
114
|
### In a GitHub repository
|
|
63
115
|
|
|
64
116
|
`test` works wherever the platform tests your code on a push:
|
|
@@ -174,22 +226,15 @@ machine, and each run starts a fresh container.
|
|
|
174
226
|
|
|
175
227
|
`context.media` is not available locally, in or out of Docker.
|
|
176
228
|
|
|
177
|
-
##
|
|
229
|
+
## Publishing
|
|
178
230
|
|
|
179
|
-
|
|
180
|
-
and
|
|
181
|
-
|
|
231
|
+
Not from here. Connect the repository to your app on the portal, push your
|
|
232
|
+
commit, and the platform runs the same checks `vsa test` runs against every
|
|
233
|
+
module it touched. Publish on the portal takes the code from a commit that
|
|
234
|
+
passed, and a version snapshot is saved so you can roll back.
|
|
182
235
|
|
|
183
236
|
Comments in `manifest.json` are a convenience of this tool: the file on your
|
|
184
|
-
disk may have them,
|
|
185
|
-
|
|
186
|
-
## CI
|
|
187
|
-
|
|
188
|
-
There is no browser on a build machine, so `login` is not available there. Set
|
|
189
|
-
`VISUALAUTOMATE_API_TOKEN` instead — the same token `login` would have stored,
|
|
190
|
-
which you can read out of `~/.visualautomate/config.json`.
|
|
191
|
-
|
|
192
|
-
`VISUALAUTOMATE_API_URL` points the CLI at a different installation.
|
|
237
|
+
disk may have them, and they are stripped before the manifest is read.
|
|
193
238
|
|
|
194
239
|
## Where things are
|
|
195
240
|
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { createServer } from "node:http";
|
|
5
4
|
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
|
|
6
|
-
import { existsSync as
|
|
7
|
-
import {
|
|
8
|
-
import { dirname as dirname2, join as join3, relative as relative3, resolve as resolve3, sep as sep2 } from "node:path";
|
|
9
|
-
import { randomBytes } from "node:crypto";
|
|
10
|
-
import { spawn } from "node:child_process";
|
|
5
|
+
import { existsSync as existsSync4, watch } from "node:fs";
|
|
6
|
+
import { join as join4, relative as relative3, resolve as resolve3, sep as sep2 } from "node:path";
|
|
11
7
|
import { createRequire } from "node:module";
|
|
12
8
|
import { build, stop as stopBundler } from "esbuild";
|
|
13
9
|
|
|
@@ -1406,6 +1402,82 @@ function loadPluginCode(code, require2) {
|
|
|
1406
1402
|
// src/declarations.ts
|
|
1407
1403
|
var TYPE_DECLARATIONS = '/**\n * What a plugin is handed, and what it must hand back.\n *\n * This file is the contract between a plugin and the VisualAutomate runtime:\n * the arguments `execute` is called with, every helper on `context`, the shape\n * of the result, and the manifest that declares the step. When a helper is only\n * present under some condition, that is said here rather than left for somebody\n * to discover from a TypeError.\n *\n * context-api.ts describes the same runtime as data, for tools that cannot read\n * a TypeScript interface, such as an editor\'s autocomplete.\n */\n\n// \u2500\u2500\u2500 What a step is called with \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The output of the steps that led here, keyed by step name and node id.\n *\n * A step reads `input.download_clips.file` where `download_clips` is what the\n * step before it is called on the canvas. Every route into this step is merged,\n * so a step at the join of two branches sees both.\n */\nexport interface PluginInput {\n [key: string]: unknown\n}\n\n/** The fields somebody filled in on the canvas, as the manifest declared them. */\nexport interface PluginConfig {\n [key: string]: unknown\n}\n\n// \u2500\u2500\u2500 context.storage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** What `storage.put` reports about the file it wrote. */\nexport interface StorageResult {\n url: string\n size: number\n contentType: string\n uploadedAt: string\n expiresAt: string\n}\n\n/** What `storage.list` reports. */\nexport interface StorageListResult {\n files: Array<{\n url: string\n pathname: string\n size: number\n uploadedAt: string\n }>\n totalSize: number\n}\n\n/**\n * The account\'s own files. Paths are scoped to the user; you cannot name\n * anybody else\'s, and you do not have to prefix your own.\n *\n * The account\'s plan caps what it may hold at once, and a `put` past that\n * ceiling is refused rather than silently dropped.\n */\nexport interface PluginStorage {\n /** Write a file. Bytes may be a string, a Buffer or any typed array. */\n put(path: string, data: Uint8Array | string, contentType?: string): Promise<StorageResult>\n /** Read a file back. `null` when it is not there. */\n get(path: string): Promise<Uint8Array | null>\n delete(path: string): Promise<void>\n /** Everything under a prefix, or everything when no prefix is given. */\n list(prefix?: string): Promise<StorageListResult>\n /** Size and content type without moving the bytes. `null` when absent. */\n head(path: string): Promise<{ size: number; contentType: string } | null>\n /**\n * A URL somebody else can fetch this file from, for a few minutes.\n *\n * For the common API that takes a link and pulls the file itself. Clamped\n * to between one minute and one hour; the default is fifteen minutes.\n */\n signedUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * Pull a URL straight into storage without the bytes passing through this\n * step. A hundred-megabyte file costs the run no memory and no egress,\n * because a Worker fetches it and writes it to storage directly.\n *\n * ONLY PRESENT when the download Worker is configured on the server. Guard\n * with `if (context.storage.download)` if you want to degrade gracefully.\n */\n download?(url: string, path: string, contentType?: string): Promise<StorageResult>\n}\n\n// \u2500\u2500\u2500 context.state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Values that outlive one run, scoped to this workflow and this account.\n *\n * For a cursor, a last-seen id, a counter \u2014 the things a workflow needs to\n * remember between firings. Not for large data: that is what storage is for.\n * The account\'s plan caps how many keys it may hold.\n */\nexport interface PluginState {\n /** The stored value, or `null` when the key has never been set. */\n get(key: string): Promise<unknown>\n set(key: string, value: unknown, options?: { ttlSeconds?: number }): Promise<void>\n delete(key: string): Promise<void>\n list(prefix?: string): Promise<{ keys: string[] }>\n}\n\n// \u2500\u2500\u2500 context.media \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * ffmpeg, for a step that has a file in storage and wants a different one.\n *\n * A PLUGIN NEVER BUILDS AN FFMPEG COMMAND LINE, and cannot. You name an\n * operation and supply values; the command line is assembled on the server, one\n * operation at a time, and the only paths in it are placeholders the container\n * fills from a list the server wrote. That is deliberate: `-i` takes any path\n * and several filters open files of their own, so an ffmpeg taking its flags\n * from plugin config would be a plugin reading the container\'s filesystem \u2014\n * including the file holding that run\'s own credentials.\n *\n * The job runs in a container of its own and talks to storage directly, so a\n * two hundred megabyte clip never crosses this step.\n *\n * ONLY PRESENT when the sandbox and storage are both configured.\n */\nexport interface PluginMedia {\n run(operation: string, params: Record<string, unknown>): Promise<Record<string, unknown>>\n}\n\n// \u2500\u2500\u2500 context.connections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** One request through the integration proxy. */\nexport interface ConnectionRequest {\n path: string\n method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"\n query?: Record<string, string | number | boolean | undefined>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** What the proxy answers with. The provider\'s own reply, unwrapped. */\nexport interface ConnectionResponse {\n ok: boolean\n status: number\n headers?: Record<string, string>\n body: unknown\n}\n\n/**\n * One of the accounts the user connected.\n *\n * THERE IS NO getToken(), deliberately. A token that can be read can be logged\n * or posted somewhere, and this is where third-party code runs. You name a path\n * and the proxy attaches the credential on the way past.\n *\n * You also never name a host. The base URL belongs to the provider\n * configuration, which is what stops a connection being pointed at an internal\n * address or at somebody\'s collection server.\n */\nexport interface PluginConnection {\n /** This platform\'s id for the connection, not the provider\'s. */\n id: string\n request(options: ConnectionRequest): Promise<ConnectionResponse>\n}\n\n/**\n * The accounts this step was configured with.\n *\n * `get` takes the name of one of your own `connection` properties \u2014 not a\n * connection id \u2014 so a step can only reach the accounts it was set up with.\n *\n * ONLY PRESENT when the step declares at least one `connection` property and\n * the user has chosen an account for it.\n */\nexport interface PluginConnections {\n get(propertyName: string): Promise<PluginConnection>\n}\n\n// \u2500\u2500\u2500 The context itself \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Everything a step is told about the run it is part of.\n *\n * The optional members are optional for a reason and not out of caution: each\n * is absent when the thing behind it is not configured or not applicable, and\n * a plugin that needs one should say so plainly rather than crash on a dot.\n */\nexport interface PluginContext {\n /** The workflow this run belongs to. */\n workflowId: string\n /** This step\'s id on the canvas. */\n nodeId: string\n /** Whose run it is. */\n userId: string\n /** This run, distinct from every other run of the same workflow. */\n executionId: string\n /** When the run started, as an ISO string. */\n startedAt: string\n /** Values the plugin was published with. Not the user\'s credentials. */\n secrets: Record<string, string>\n\n /** The account\'s files. See PluginStorage. */\n storage: PluginStorage\n /** Values that outlive one run. See PluginState. */\n state: PluginState\n /** ffmpeg. Absent when the server has no sandbox or storage configured. */\n media?: PluginMedia\n /** The accounts this step was configured with. Absent when it has none. */\n connections?: PluginConnections\n}\n\n// \u2500\u2500\u2500 What a step hands back \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The result of a step.\n *\n * `output` names which port the run leaves by, and therefore which lines on the\n * canvas are followed. It has to be one of the `outputs` in the manifest \u2014\n * usually `"success"` or `"error"`.\n *\n * `data` is what the next steps read as `{{this_step.field}}`. Keep it flat and\n * named for what it is; those names are what somebody picks from a dropdown.\n */\nexport interface PluginOutput {\n output: string\n data: Record<string, unknown>\n metadata?: {\n durationMs?: number\n memoryUsedMb?: number\n logs?: string[]\n }\n}\n\n/**\n * A plugin.\n *\n * Export `execute`, either as `module.exports.execute` or as the default export\n * of a module with an `execute` on it. Both are accepted, and so is a bare\n * function as the default export.\n *\n * @example\n * export default {\n * async execute(input, config, context) {\n * const file = await context.storage.get(config.path as string)\n * return { output: "success", data: { bytes: file?.length ?? 0 } }\n * }\n * }\n */\nexport interface PluginModule {\n execute(\n input: PluginInput,\n config: PluginConfig,\n context: PluginContext,\n ): Promise<PluginOutput> | PluginOutput\n}\n\n/** The bare function form, for a plugin that is only an execute. */\nexport type PluginExecute = PluginModule["execute"]\n\n// \u2500\u2500\u2500 How a run reports on a step \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * `skipped` is a decision rather than a fault: the filters on every line into\n * the step said no, so it never ran. Counting it as an error turns a branch\n * that was simply not chosen into a broken workflow.\n */\nexport type ExecutionStatus = "success" | "error" | "timeout" | "skipped" | "cancelled"\n\nexport interface PluginExecutionResult {\n status: ExecutionStatus\n output: PluginOutput | null\n error?: string\n durationMs: number\n memoryMb?: number\n outputPort?: string\n logs?: string[]\n}\n\n// \u2500\u2500\u2500 The manifest \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The declaration that sits beside the code.\n *\n * It says which fields the canvas renders, which account the step acts as, what\n * it hands downstream, and whether it is a starting point that watches\n * something. One shape wherever it is stored \u2014 `manifest.json` next to your\n * code, or an `@schema` comment in the source.\n *\n * Every name below is one the platform keeps. A block it does not recognise is\n * dropped when the plugin is saved, so a field that is not listed here will not\n * survive an upload.\n */\nexport type PluginPropertyType =\n | "string"\n | "number"\n | "boolean"\n | "select"\n | "multiselect"\n | "json"\n /**\n * A program, not a value.\n *\n * Stored exactly as typed and never parsed, which is the difference from\n * `json` above: that one keeps its value structured, so `return 5` would be\n * saved as the number five and the step would run nothing.\n */\n | "code"\n | "color"\n | "date"\n /** An account the user connected. Rendered as a picker, not a text box. */\n | "connection"\n /**\n * A file the account already has, read into the config before the step\n * runs. `{{config.attachment.name}}`, `.contentType`, `.size`,\n * `.contentBytes`. Right for attaching a PDF; a video would be hundreds of\n * megabytes of base64 \u2014 use `path` for those.\n */\n | "file"\n /** A file in storage, named rather than read. What ffmpeg steps take. */\n | "path"\n /** Several of them, one per line, in the order they were chosen. */\n | "paths"\n\nexport interface PluginProperty {\n /** Shown above the input on the canvas. Defaults to the property name. */\n label: string\n type: PluginPropertyType\n required?: boolean\n default?: unknown\n /** Helper text under the input. */\n description?: string\n /** For `select` and `multiselect`. */\n options?: Array<{ label: string; value: string }>\n /** Puts related fields in a section together. */\n group?: string\n /** For `number`. */\n min?: number\n max?: number\n /** For `string`: a regex the value must match. */\n pattern?: string\n /**\n * For `connection`: which integration the account must belong to, e.g.\n * `outlook`. A module inherits its app\'s, so it does not repeat this.\n */\n provider?: string\n}\n\n/**\n * How a step reaches the account it acts as.\n *\n * `none` means the plugin holds its own key in a property. That is not the same\n * as leaving the block out, which means "written before auth could be\n * declared".\n *\n * A MANIFEST MAY NOT NAME A HOST. `url`, `baseUrl`, `base_url` and `host` are\n * refused outright rather than ignored, so nobody writes one and believes it\n * took effect: the base URL belongs to the provider configuration, and that is\n * what stops a connection being pointed at an internal address.\n */\nexport interface PluginAuth {\n type: "oauth2" | "api_key" | "basic" | "none"\n provider?: string\n scopes?: string[]\n}\n\n/** One request the platform makes on your behalf. Relative paths only. */\nexport interface TriggerRequest {\n method?: "GET" | "POST"\n /** Relative to the provider\'s base URL. The proxy refuses anything else. */\n path: string\n query?: Record<string, string>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** Where the next poll should carry on from. */\nexport interface TriggerCursor {\n from: "response" | "item"\n /** Dotted path to the value. `@odata.deltaLink` is one key, not two. */\n path: string\n into?: string\n}\n\n/**\n * A second request per item, for providers that answer with identifiers.\n *\n * Gmail\'s message list returns `{ id, threadId }` \u2014 enough to know a mail\n * arrived, not enough to tell anyone what it says. Without this the trigger\n * polls correctly, dedupes correctly, and hands every workflow an empty\n * message, which is the worst kind of working.\n */\nexport interface TriggerHydrate {\n path: string\n method?: "GET" | "POST"\n query?: Record<string, string>\n headers?: Record<string, string>\n}\n\n/** For providers that can tell us instead of being asked. */\nexport interface TriggerPush {\n resource: string\n changeType: string\n maxMinutes?: number\n}\n\n/**\n * What makes a plugin a starting point.\n *\n * A declaration rather than code, because the runtime is billed by the second\n * and a step blocking on an empty inbox would be paid for every second of doing\n * nothing. The platform polls for you and starts the workflow when something\n * arrives.\n */\nexport interface PluginTrigger {\n /** Must name one of your own properties of type `connection`. */\n connection: string\n /** Minutes between checks. The account\'s plan sets the floor. */\n poll?: string | number\n request: TriggerRequest\n /** Dotted path to the array of items in the response. */\n items: string\n /** Dotted path, within one item, to what makes it unique. */\n id: string\n cursor?: TriggerCursor\n hydrate?: TriggerHydrate\n push?: TriggerPush\n /** How many items one poll may start workflows for. */\n maxPerPoll?: number\n}\n\n/**\n * A step that is a declaration rather than a program.\n *\n * One request built from the properties somebody filled in, or no request at\n * all for a step that only reshapes what reached it. No container, no code, and\n * nothing for a review to be about \u2014 which is why most steps should be one.\n *\n * Left loosely typed here on purpose: writing an action is a different job from\n * writing a plugin, and the platform validates its shape when it is saved.\n */\nexport type PluginAction = Record<string, unknown>\n\n/**\n * Every block the validator keeps. Anything else is dropped on save.\n *\n * Note what is NOT here: the hosts a plugin may contact are set on the platform\n * rather than declared in the manifest, so a plugin cannot widen its own egress.\n */\nexport interface PluginManifest {\n description?: string\n icon?: string\n color?: string\n /** Bumped by you when the declaration changes shape. */\n version?: number\n /** How long one run may take. Capped by the account\'s plan. */\n timeoutMs?: number\n memoryLimitMb?: number\n auth?: PluginAuth\n properties?: Record<string, PluginProperty>\n inputs?: string[]\n outputs?: string[]\n /** What downstream steps can read, described for the picker. */\n outputFields?: Record<string, string>\n trigger?: PluginTrigger\n action?: PluginAction\n}\n\n/** The older name for the same thing, kept because authors still say it. */\nexport type PluginSchema = PluginManifest\n';
|
|
1408
1404
|
|
|
1405
|
+
// src/repo-types.ts
|
|
1406
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
1407
|
+
import { basename, dirname, join } from "node:path";
|
|
1408
|
+
|
|
1409
|
+
// src/ambient-declarations.ts
|
|
1410
|
+
var AMBIENT_DECLARATIONS = '/**\n * What a plugin is handed, and what it must hand back.\n *\n * This file is the contract between a plugin and the VisualAutomate runtime:\n * the arguments `execute` is called with, every helper on `context`, the shape\n * of the result, and the manifest that declares the step. When a helper is only\n * present under some condition, that is said here rather than left for somebody\n * to discover from a TypeError.\n *\n * context-api.ts describes the same runtime as data, for tools that cannot read\n * a TypeScript interface, such as an editor\'s autocomplete.\n */\n\n// \u2500\u2500\u2500 What a step is called with \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The output of the steps that led here, keyed by step name and node id.\n *\n * A step reads `input.download_clips.file` where `download_clips` is what the\n * step before it is called on the canvas. Every route into this step is merged,\n * so a step at the join of two branches sees both.\n */\ndeclare interface PluginInput {\n [key: string]: unknown\n}\n\n/** The fields somebody filled in on the canvas, as the manifest declared them. */\ndeclare interface PluginConfig {\n [key: string]: unknown\n}\n\n// \u2500\u2500\u2500 context.storage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** What `storage.put` reports about the file it wrote. */\ndeclare interface StorageResult {\n url: string\n size: number\n contentType: string\n uploadedAt: string\n expiresAt: string\n}\n\n/** What `storage.list` reports. */\ndeclare interface StorageListResult {\n files: Array<{\n url: string\n pathname: string\n size: number\n uploadedAt: string\n }>\n totalSize: number\n}\n\n/**\n * The account\'s own files. Paths are scoped to the user; you cannot name\n * anybody else\'s, and you do not have to prefix your own.\n *\n * The account\'s plan caps what it may hold at once, and a `put` past that\n * ceiling is refused rather than silently dropped.\n */\ndeclare interface PluginStorage {\n /** Write a file. Bytes may be a string, a Buffer or any typed array. */\n put(path: string, data: Uint8Array | string, contentType?: string): Promise<StorageResult>\n /** Read a file back. `null` when it is not there. */\n get(path: string): Promise<Uint8Array | null>\n delete(path: string): Promise<void>\n /** Everything under a prefix, or everything when no prefix is given. */\n list(prefix?: string): Promise<StorageListResult>\n /** Size and content type without moving the bytes. `null` when absent. */\n head(path: string): Promise<{ size: number; contentType: string } | null>\n /**\n * A URL somebody else can fetch this file from, for a few minutes.\n *\n * For the common API that takes a link and pulls the file itself. Clamped\n * to between one minute and one hour; the default is fifteen minutes.\n */\n signedUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * Pull a URL straight into storage without the bytes passing through this\n * step. A hundred-megabyte file costs the run no memory and no egress,\n * because a Worker fetches it and writes it to storage directly.\n *\n * ONLY PRESENT when the download Worker is configured on the server. Guard\n * with `if (context.storage.download)` if you want to degrade gracefully.\n */\n download?(url: string, path: string, contentType?: string): Promise<StorageResult>\n}\n\n// \u2500\u2500\u2500 context.state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Values that outlive one run, scoped to this workflow and this account.\n *\n * For a cursor, a last-seen id, a counter \u2014 the things a workflow needs to\n * remember between firings. Not for large data: that is what storage is for.\n * The account\'s plan caps how many keys it may hold.\n */\ndeclare interface PluginState {\n /** The stored value, or `null` when the key has never been set. */\n get(key: string): Promise<unknown>\n set(key: string, value: unknown, options?: { ttlSeconds?: number }): Promise<void>\n delete(key: string): Promise<void>\n list(prefix?: string): Promise<{ keys: string[] }>\n}\n\n// \u2500\u2500\u2500 context.media \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * ffmpeg, for a step that has a file in storage and wants a different one.\n *\n * A PLUGIN NEVER BUILDS AN FFMPEG COMMAND LINE, and cannot. You name an\n * operation and supply values; the command line is assembled on the server, one\n * operation at a time, and the only paths in it are placeholders the container\n * fills from a list the server wrote. That is deliberate: `-i` takes any path\n * and several filters open files of their own, so an ffmpeg taking its flags\n * from plugin config would be a plugin reading the container\'s filesystem \u2014\n * including the file holding that run\'s own credentials.\n *\n * The job runs in a container of its own and talks to storage directly, so a\n * two hundred megabyte clip never crosses this step.\n *\n * ONLY PRESENT when the sandbox and storage are both configured.\n */\ndeclare interface PluginMedia {\n run(operation: string, params: Record<string, unknown>): Promise<Record<string, unknown>>\n}\n\n// \u2500\u2500\u2500 context.connections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** One request through the integration proxy. */\ndeclare interface ConnectionRequest {\n path: string\n method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"\n query?: Record<string, string | number | boolean | undefined>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** What the proxy answers with. The provider\'s own reply, unwrapped. */\ndeclare interface ConnectionResponse {\n ok: boolean\n status: number\n headers?: Record<string, string>\n body: unknown\n}\n\n/**\n * One of the accounts the user connected.\n *\n * THERE IS NO getToken(), deliberately. A token that can be read can be logged\n * or posted somewhere, and this is where third-party code runs. You name a path\n * and the proxy attaches the credential on the way past.\n *\n * You also never name a host. The base URL belongs to the provider\n * configuration, which is what stops a connection being pointed at an internal\n * address or at somebody\'s collection server.\n */\ndeclare interface PluginConnection {\n /** This platform\'s id for the connection, not the provider\'s. */\n id: string\n request(options: ConnectionRequest): Promise<ConnectionResponse>\n}\n\n/**\n * The accounts this step was configured with.\n *\n * `get` takes the name of one of your own `connection` properties \u2014 not a\n * connection id \u2014 so a step can only reach the accounts it was set up with.\n *\n * ONLY PRESENT when the step declares at least one `connection` property and\n * the user has chosen an account for it.\n */\ndeclare interface PluginConnections {\n get(propertyName: string): Promise<PluginConnection>\n}\n\n// \u2500\u2500\u2500 The context itself \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Everything a step is told about the run it is part of.\n *\n * The optional members are optional for a reason and not out of caution: each\n * is absent when the thing behind it is not configured or not applicable, and\n * a plugin that needs one should say so plainly rather than crash on a dot.\n */\ndeclare interface PluginContext {\n /** The workflow this run belongs to. */\n workflowId: string\n /** This step\'s id on the canvas. */\n nodeId: string\n /** Whose run it is. */\n userId: string\n /** This run, distinct from every other run of the same workflow. */\n executionId: string\n /** When the run started, as an ISO string. */\n startedAt: string\n /** Values the plugin was published with. Not the user\'s credentials. */\n secrets: Record<string, string>\n\n /** The account\'s files. See PluginStorage. */\n storage: PluginStorage\n /** Values that outlive one run. See PluginState. */\n state: PluginState\n /** ffmpeg. Absent when the server has no sandbox or storage configured. */\n media?: PluginMedia\n /** The accounts this step was configured with. Absent when it has none. */\n connections?: PluginConnections\n}\n\n// \u2500\u2500\u2500 What a step hands back \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The result of a step.\n *\n * `output` names which port the run leaves by, and therefore which lines on the\n * canvas are followed. It has to be one of the `outputs` in the manifest \u2014\n * usually `"success"` or `"error"`.\n *\n * `data` is what the next steps read as `{{this_step.field}}`. Keep it flat and\n * named for what it is; those names are what somebody picks from a dropdown.\n */\ndeclare interface PluginOutput {\n output: string\n data: Record<string, unknown>\n metadata?: {\n durationMs?: number\n memoryUsedMb?: number\n logs?: string[]\n }\n}\n\n/**\n * A plugin.\n *\n * Export `execute`, either as `module.exports.execute` or as the default export\n * of a module with an `execute` on it. Both are accepted, and so is a bare\n * function as the default export.\n *\n * @example\n * export default {\n * async execute(input, config, context) {\n * const file = await context.storage.get(config.path as string)\n * return { output: "success", data: { bytes: file?.length ?? 0 } }\n * }\n * }\n */\ndeclare interface PluginModule {\n execute(\n input: PluginInput,\n config: PluginConfig,\n context: PluginContext,\n ): Promise<PluginOutput> | PluginOutput\n}\n\n/** The bare function form, for a plugin that is only an execute. */\ndeclare type PluginExecute = PluginModule["execute"]\n\n// \u2500\u2500\u2500 How a run reports on a step \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * `skipped` is a decision rather than a fault: the filters on every line into\n * the step said no, so it never ran. Counting it as an error turns a branch\n * that was simply not chosen into a broken workflow.\n */\ndeclare type ExecutionStatus = "success" | "error" | "timeout" | "skipped" | "cancelled"\n\ndeclare interface PluginExecutionResult {\n status: ExecutionStatus\n output: PluginOutput | null\n error?: string\n durationMs: number\n memoryMb?: number\n outputPort?: string\n logs?: string[]\n}\n\n// \u2500\u2500\u2500 The manifest \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The declaration that sits beside the code.\n *\n * It says which fields the canvas renders, which account the step acts as, what\n * it hands downstream, and whether it is a starting point that watches\n * something. One shape wherever it is stored \u2014 `manifest.json` next to your\n * code, or an `@schema` comment in the source.\n *\n * Every name below is one the platform keeps. A block it does not recognise is\n * dropped when the plugin is saved, so a field that is not listed here will not\n * survive an upload.\n */\ndeclare type PluginPropertyType =\n | "string"\n | "number"\n | "boolean"\n | "select"\n | "multiselect"\n | "json"\n /**\n * A program, not a value.\n *\n * Stored exactly as typed and never parsed, which is the difference from\n * `json` above: that one keeps its value structured, so `return 5` would be\n * saved as the number five and the step would run nothing.\n */\n | "code"\n | "color"\n | "date"\n /** An account the user connected. Rendered as a picker, not a text box. */\n | "connection"\n /**\n * A file the account already has, read into the config before the step\n * runs. `{{config.attachment.name}}`, `.contentType`, `.size`,\n * `.contentBytes`. Right for attaching a PDF; a video would be hundreds of\n * megabytes of base64 \u2014 use `path` for those.\n */\n | "file"\n /** A file in storage, named rather than read. What ffmpeg steps take. */\n | "path"\n /** Several of them, one per line, in the order they were chosen. */\n | "paths"\n\ndeclare interface PluginProperty {\n /** Shown above the input on the canvas. Defaults to the property name. */\n label: string\n type: PluginPropertyType\n required?: boolean\n default?: unknown\n /** Helper text under the input. */\n description?: string\n /** For `select` and `multiselect`. */\n options?: Array<{ label: string; value: string }>\n /** Puts related fields in a section together. */\n group?: string\n /** For `number`. */\n min?: number\n max?: number\n /** For `string`: a regex the value must match. */\n pattern?: string\n /**\n * For `connection`: which integration the account must belong to, e.g.\n * `outlook`. A module inherits its app\'s, so it does not repeat this.\n */\n provider?: string\n}\n\n/**\n * How a step reaches the account it acts as.\n *\n * `none` means the plugin holds its own key in a property. That is not the same\n * as leaving the block out, which means "written before auth could be\n * declared".\n *\n * A MANIFEST MAY NOT NAME A HOST. `url`, `baseUrl`, `base_url` and `host` are\n * refused outright rather than ignored, so nobody writes one and believes it\n * took effect: the base URL belongs to the provider configuration, and that is\n * what stops a connection being pointed at an internal address.\n */\ndeclare interface PluginAuth {\n type: "oauth2" | "api_key" | "basic" | "none"\n provider?: string\n scopes?: string[]\n}\n\n/** One request the platform makes on your behalf. Relative paths only. */\ndeclare interface TriggerRequest {\n method?: "GET" | "POST"\n /** Relative to the provider\'s base URL. The proxy refuses anything else. */\n path: string\n query?: Record<string, string>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** Where the next poll should carry on from. */\ndeclare interface TriggerCursor {\n from: "response" | "item"\n /** Dotted path to the value. `@odata.deltaLink` is one key, not two. */\n path: string\n into?: string\n}\n\n/**\n * A second request per item, for providers that answer with identifiers.\n *\n * Gmail\'s message list returns `{ id, threadId }` \u2014 enough to know a mail\n * arrived, not enough to tell anyone what it says. Without this the trigger\n * polls correctly, dedupes correctly, and hands every workflow an empty\n * message, which is the worst kind of working.\n */\ndeclare interface TriggerHydrate {\n path: string\n method?: "GET" | "POST"\n query?: Record<string, string>\n headers?: Record<string, string>\n}\n\n/** For providers that can tell us instead of being asked. */\ndeclare interface TriggerPush {\n resource: string\n changeType: string\n maxMinutes?: number\n}\n\n/**\n * What makes a plugin a starting point.\n *\n * A declaration rather than code, because the runtime is billed by the second\n * and a step blocking on an empty inbox would be paid for every second of doing\n * nothing. The platform polls for you and starts the workflow when something\n * arrives.\n */\ndeclare interface PluginTrigger {\n /** Must name one of your own properties of type `connection`. */\n connection: string\n /** Minutes between checks. The account\'s plan sets the floor. */\n poll?: string | number\n request: TriggerRequest\n /** Dotted path to the array of items in the response. */\n items: string\n /** Dotted path, within one item, to what makes it unique. */\n id: string\n cursor?: TriggerCursor\n hydrate?: TriggerHydrate\n push?: TriggerPush\n /** How many items one poll may start workflows for. */\n maxPerPoll?: number\n}\n\n/**\n * A step that is a declaration rather than a program.\n *\n * One request built from the properties somebody filled in, or no request at\n * all for a step that only reshapes what reached it. No container, no code, and\n * nothing for a review to be about \u2014 which is why most steps should be one.\n *\n * Left loosely typed here on purpose: writing an action is a different job from\n * writing a plugin, and the platform validates its shape when it is saved.\n */\ndeclare type PluginAction = Record<string, unknown>\n\n/**\n * Every block the validator keeps. Anything else is dropped on save.\n *\n * Note what is NOT here: the hosts a plugin may contact are set on the platform\n * rather than declared in the manifest, so a plugin cannot widen its own egress.\n */\ndeclare interface PluginManifest {\n description?: string\n icon?: string\n color?: string\n /** Bumped by you when the declaration changes shape. */\n version?: number\n /** How long one run may take. Capped by the account\'s plan. */\n timeoutMs?: number\n memoryLimitMb?: number\n auth?: PluginAuth\n properties?: Record<string, PluginProperty>\n inputs?: string[]\n outputs?: string[]\n /** What downstream steps can read, described for the picker. */\n outputFields?: Record<string, string>\n trigger?: PluginTrigger\n action?: PluginAction\n}\n\n/** The older name for the same thing, kept because authors still say it. */\ndeclare type PluginSchema = PluginManifest\n';
|
|
1411
|
+
|
|
1412
|
+
// src/repo-types.ts
|
|
1413
|
+
var TYPES_FILE = "visualautomate.d.ts";
|
|
1414
|
+
var JSCONFIG_FILE = "jsconfig.json";
|
|
1415
|
+
var TYPES_MARKER = "// The VisualAutomate plugin contract, for your editor.";
|
|
1416
|
+
function declarationsFor(version) {
|
|
1417
|
+
return `${TYPES_MARKER}
|
|
1418
|
+
//
|
|
1419
|
+
// Written by the VisualAutomate CLI ${version}. Nothing here exists while your
|
|
1420
|
+
// plugin runs, and your code imports none of it: the line "@type {PluginModule}"
|
|
1421
|
+
// above module.exports is what connects the two.
|
|
1422
|
+
//
|
|
1423
|
+
// Edits to this file are overwritten. Delete it and it is written again.
|
|
1424
|
+
|
|
1425
|
+
${AMBIENT_DECLARATIONS}`;
|
|
1426
|
+
}
|
|
1427
|
+
var JSCONFIG = `${JSON.stringify(
|
|
1428
|
+
{
|
|
1429
|
+
compilerOptions: {
|
|
1430
|
+
target: "ES2022",
|
|
1431
|
+
lib: ["ES2022"],
|
|
1432
|
+
module: "node16",
|
|
1433
|
+
moduleResolution: "node16",
|
|
1434
|
+
allowJs: true,
|
|
1435
|
+
// Off on purpose: a plugin may require any of the packages the
|
|
1436
|
+
// sandbox has, which are installed there and not here, and checking
|
|
1437
|
+
// would report every one of them as a module that cannot be found.
|
|
1438
|
+
// Completion and hover documentation do not need it.
|
|
1439
|
+
checkJs: false,
|
|
1440
|
+
noEmit: true
|
|
1441
|
+
},
|
|
1442
|
+
include: [TYPES_FILE, "*.js", "modules/**/*.js", "examples/**/*.js"],
|
|
1443
|
+
exclude: ["node_modules", ".visualautomate"]
|
|
1444
|
+
},
|
|
1445
|
+
null,
|
|
1446
|
+
2
|
|
1447
|
+
)}
|
|
1448
|
+
`;
|
|
1449
|
+
function ensureRepoTypes(root, version) {
|
|
1450
|
+
const written = [];
|
|
1451
|
+
const typesPath = join(root, TYPES_FILE);
|
|
1452
|
+
const wanted2 = declarationsFor(version);
|
|
1453
|
+
const current = existsSync(typesPath) ? read(typesPath) : null;
|
|
1454
|
+
if (current === null || current.startsWith(TYPES_MARKER) && current !== wanted2) {
|
|
1455
|
+
writeFileSync(typesPath, wanted2);
|
|
1456
|
+
written.push(TYPES_FILE);
|
|
1457
|
+
}
|
|
1458
|
+
const jsconfigPath = join(root, JSCONFIG_FILE);
|
|
1459
|
+
if (!existsSync(jsconfigPath)) {
|
|
1460
|
+
writeFileSync(jsconfigPath, JSCONFIG);
|
|
1461
|
+
written.push(JSCONFIG_FILE);
|
|
1462
|
+
}
|
|
1463
|
+
return written;
|
|
1464
|
+
}
|
|
1465
|
+
function read(path) {
|
|
1466
|
+
try {
|
|
1467
|
+
return readFileSync(path, "utf8");
|
|
1468
|
+
} catch {
|
|
1469
|
+
return null;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
function projectRoot(cwd) {
|
|
1473
|
+
const parent = dirname(cwd);
|
|
1474
|
+
if (basename(parent) === "modules") {
|
|
1475
|
+
const root = dirname(parent);
|
|
1476
|
+
if (existsSync(join(root, "package.json")) || existsSync(join(root, TYPES_FILE))) return root;
|
|
1477
|
+
}
|
|
1478
|
+
return cwd;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1409
1481
|
// src/templates.ts
|
|
1410
1482
|
var EXAMPLES = `
|
|
1411
1483
|
// -- The rest of what you can declare --------------------------------
|
|
@@ -1813,28 +1885,231 @@ function parseJsonc(text) {
|
|
|
1813
1885
|
return { value, json: JSON.stringify(value, null, 2) };
|
|
1814
1886
|
}
|
|
1815
1887
|
|
|
1888
|
+
// src/manifest-property.ts
|
|
1889
|
+
var PROPERTY_TYPES = [
|
|
1890
|
+
"string",
|
|
1891
|
+
"number",
|
|
1892
|
+
"boolean",
|
|
1893
|
+
"select",
|
|
1894
|
+
"multiselect",
|
|
1895
|
+
"json",
|
|
1896
|
+
"color",
|
|
1897
|
+
"date",
|
|
1898
|
+
"connection",
|
|
1899
|
+
"file",
|
|
1900
|
+
"path",
|
|
1901
|
+
"paths"
|
|
1902
|
+
];
|
|
1903
|
+
function isPropertyType(value) {
|
|
1904
|
+
return PROPERTY_TYPES.includes(value);
|
|
1905
|
+
}
|
|
1906
|
+
function isPropertyName(name) {
|
|
1907
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
|
1908
|
+
}
|
|
1909
|
+
function labelFor(name) {
|
|
1910
|
+
const words = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim().toLowerCase();
|
|
1911
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
1912
|
+
}
|
|
1913
|
+
function defaultFor(type, raw) {
|
|
1914
|
+
switch (type) {
|
|
1915
|
+
case "number": {
|
|
1916
|
+
const value = Number(raw);
|
|
1917
|
+
if (raw.trim() === "" || Number.isNaN(value)) throw new Error(`"${raw}" is not a number.`);
|
|
1918
|
+
return value;
|
|
1919
|
+
}
|
|
1920
|
+
case "boolean": {
|
|
1921
|
+
const value = raw.trim().toLowerCase();
|
|
1922
|
+
if (["true", "yes", "1"].includes(value)) return true;
|
|
1923
|
+
if (["false", "no", "0"].includes(value)) return false;
|
|
1924
|
+
throw new Error(`"${raw}" is not true or false.`);
|
|
1925
|
+
}
|
|
1926
|
+
case "json":
|
|
1927
|
+
try {
|
|
1928
|
+
return JSON.parse(raw);
|
|
1929
|
+
} catch {
|
|
1930
|
+
throw new Error(`"${raw}" is not JSON. Quote it as your shell needs: '{"a":1}'`);
|
|
1931
|
+
}
|
|
1932
|
+
case "multiselect":
|
|
1933
|
+
case "paths":
|
|
1934
|
+
return raw.trim() === "" ? [] : raw.split(",").map((part) => part.trim()).filter(Boolean);
|
|
1935
|
+
default:
|
|
1936
|
+
return raw;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
function optionsFrom(raw) {
|
|
1940
|
+
return raw.split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
1941
|
+
const at = part.indexOf(":");
|
|
1942
|
+
if (at === -1) return { label: labelFor(part), value: part };
|
|
1943
|
+
return { label: part.slice(0, at).trim(), value: part.slice(at + 1).trim() };
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
function buildProperty(spec) {
|
|
1947
|
+
const property = {
|
|
1948
|
+
label: spec.label?.trim() || labelFor(spec.name),
|
|
1949
|
+
type: spec.type
|
|
1950
|
+
};
|
|
1951
|
+
if (spec.type === "select" || spec.type === "multiselect") {
|
|
1952
|
+
const options = optionsFrom(spec.options ?? "");
|
|
1953
|
+
if (options.length === 0) {
|
|
1954
|
+
throw new Error(
|
|
1955
|
+
`A ${spec.type} needs its choices: --options "Fast:fast,Thorough:thorough", or --options fast,thorough.`
|
|
1956
|
+
);
|
|
1957
|
+
}
|
|
1958
|
+
property.options = options;
|
|
1959
|
+
}
|
|
1960
|
+
if (spec.type === "connection") {
|
|
1961
|
+
if (!spec.provider) {
|
|
1962
|
+
throw new Error("A connection needs the provider it is for: --provider outlook.");
|
|
1963
|
+
}
|
|
1964
|
+
property.provider = spec.provider;
|
|
1965
|
+
}
|
|
1966
|
+
if (spec.required) property.required = true;
|
|
1967
|
+
if (spec.value !== void 0) property.default = defaultFor(spec.type, spec.value);
|
|
1968
|
+
if (spec.description?.trim()) property.description = spec.description.trim();
|
|
1969
|
+
return property;
|
|
1970
|
+
}
|
|
1971
|
+
function topLevelMembers(bare) {
|
|
1972
|
+
const members = /* @__PURE__ */ new Map();
|
|
1973
|
+
let object = null;
|
|
1974
|
+
let depth = 0;
|
|
1975
|
+
let key = null;
|
|
1976
|
+
let expectValue = false;
|
|
1977
|
+
for (let i = 0; i < bare.length; i++) {
|
|
1978
|
+
const char = bare[i];
|
|
1979
|
+
if (char === '"') {
|
|
1980
|
+
let end = i + 1;
|
|
1981
|
+
while (end < bare.length && bare[end] !== '"') end += bare[end] === "\\" ? 2 : 1;
|
|
1982
|
+
if (depth === 1 && !expectValue) key = bare.slice(i + 1, end);
|
|
1983
|
+
if (depth === 1 && expectValue && key) {
|
|
1984
|
+
members.set(key, { start: i, end });
|
|
1985
|
+
key = null;
|
|
1986
|
+
expectValue = false;
|
|
1987
|
+
}
|
|
1988
|
+
i = end;
|
|
1989
|
+
continue;
|
|
1990
|
+
}
|
|
1991
|
+
if (char === ":" && depth === 1 && key) {
|
|
1992
|
+
expectValue = true;
|
|
1993
|
+
continue;
|
|
1994
|
+
}
|
|
1995
|
+
if (char === "{" || char === "[") {
|
|
1996
|
+
depth++;
|
|
1997
|
+
if (depth === 1) object = { start: i, end: -1 };
|
|
1998
|
+
if (depth === 2 && expectValue && key) {
|
|
1999
|
+
const close = matchBracket(bare, i);
|
|
2000
|
+
members.set(key, { start: i, end: close });
|
|
2001
|
+
key = null;
|
|
2002
|
+
expectValue = false;
|
|
2003
|
+
i = close;
|
|
2004
|
+
depth--;
|
|
2005
|
+
}
|
|
2006
|
+
continue;
|
|
2007
|
+
}
|
|
2008
|
+
if (char === "}" || char === "]") {
|
|
2009
|
+
if (depth === 1 && object) object.end = i;
|
|
2010
|
+
depth--;
|
|
2011
|
+
continue;
|
|
2012
|
+
}
|
|
2013
|
+
if (depth === 1 && expectValue && !/[\s,]/.test(char)) {
|
|
2014
|
+
let end = i;
|
|
2015
|
+
while (end < bare.length && !/[,}\]]/.test(bare[end])) end++;
|
|
2016
|
+
if (key) members.set(key, { start: i, end: end - 1 });
|
|
2017
|
+
key = null;
|
|
2018
|
+
expectValue = false;
|
|
2019
|
+
i = end - 1;
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
return { object, members };
|
|
2023
|
+
}
|
|
2024
|
+
function matchBracket(bare, open) {
|
|
2025
|
+
let depth = 0;
|
|
2026
|
+
for (let i = open; i < bare.length; i++) {
|
|
2027
|
+
const char = bare[i];
|
|
2028
|
+
if (char === '"') {
|
|
2029
|
+
i++;
|
|
2030
|
+
while (i < bare.length && bare[i] !== '"') i += bare[i] === "\\" ? 2 : 1;
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
if (char === "{" || char === "[") depth++;
|
|
2034
|
+
if (char === "}" || char === "]") {
|
|
2035
|
+
depth--;
|
|
2036
|
+
if (depth === 0) return i;
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
throw new Error("This manifest has a bracket that is never closed.");
|
|
2040
|
+
}
|
|
2041
|
+
function indentOf(text) {
|
|
2042
|
+
const found = /\n([ \t]+)"/.exec(text);
|
|
2043
|
+
return found ? found[1] : " ";
|
|
2044
|
+
}
|
|
2045
|
+
function lastContent(bare, from, to) {
|
|
2046
|
+
for (let i = to - 1; i > from; i--) {
|
|
2047
|
+
if (!/\s/.test(bare[i])) return i;
|
|
2048
|
+
}
|
|
2049
|
+
return -1;
|
|
2050
|
+
}
|
|
2051
|
+
function indented(value, indent, depth) {
|
|
2052
|
+
return JSON.stringify(value, null, indent.length === 1 ? " " : indent.length).replace(/\{\s*"label": ("(?:[^"\\]|\\.)*"),\s*"value": ("(?:[^"\\]|\\.)*")\s*\}/g, '{ "label": $1, "value": $2 }').split("\n").map((line2, index) => index === 0 ? line2 : indent.repeat(depth) + line2).join("\n");
|
|
2053
|
+
}
|
|
2054
|
+
function withProperty(text, name, property) {
|
|
2055
|
+
const bare = stripJsonComments(text);
|
|
2056
|
+
const { object, members } = topLevelMembers(bare);
|
|
2057
|
+
if (!object || object.end === -1) throw new Error("This manifest is not a JSON object.");
|
|
2058
|
+
const indent = indentOf(text);
|
|
2059
|
+
const entry = `"${name}": ${indented(property, indent, 2)}`;
|
|
2060
|
+
const existing = members.get("properties");
|
|
2061
|
+
let next;
|
|
2062
|
+
if (existing && bare[existing.start] === "{") {
|
|
2063
|
+
const last = lastContent(bare, existing.start, existing.end);
|
|
2064
|
+
next = last === -1 ? text.slice(0, existing.start + 1) + `
|
|
2065
|
+
${indent.repeat(2)}${entry}
|
|
2066
|
+
${indent}` + text.slice(existing.end) : text.slice(0, last + 1) + `,
|
|
2067
|
+
${indent.repeat(2)}${entry}` + text.slice(last + 1);
|
|
2068
|
+
} else if (existing) {
|
|
2069
|
+
throw new Error('"properties" in this manifest is not an object, so nothing can be added to it.');
|
|
2070
|
+
} else {
|
|
2071
|
+
const block = `"properties": {
|
|
2072
|
+
${indent.repeat(2)}${entry}
|
|
2073
|
+
${indent}}`;
|
|
2074
|
+
const last = lastContent(bare, object.start, object.end);
|
|
2075
|
+
next = last === -1 ? text.slice(0, object.start + 1) + `
|
|
2076
|
+
${indent}${block}
|
|
2077
|
+
` + text.slice(object.end) : text.slice(0, last + 1) + `,
|
|
2078
|
+
${indent}${block}` + text.slice(last + 1);
|
|
2079
|
+
}
|
|
2080
|
+
const parsed = JSON.parse(stripJsonComments(next));
|
|
2081
|
+
if (!parsed.properties || !(name in parsed.properties)) {
|
|
2082
|
+
throw new Error("The property could not be placed in this manifest. Add it by hand, or say where this went wrong.");
|
|
2083
|
+
}
|
|
2084
|
+
return next;
|
|
2085
|
+
}
|
|
2086
|
+
function hasProperty(text, name) {
|
|
2087
|
+
const parsed = JSON.parse(stripJsonComments(text));
|
|
2088
|
+
return Boolean(parsed.properties && typeof parsed.properties === "object" && name in parsed.properties);
|
|
2089
|
+
}
|
|
2090
|
+
|
|
1816
2091
|
// src/project.ts
|
|
1817
|
-
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2092
|
+
import { existsSync as existsSync2, readdirSync, statSync } from "node:fs";
|
|
1818
2093
|
import { readFile } from "node:fs/promises";
|
|
1819
|
-
import { basename, join, relative, resolve } from "node:path";
|
|
2094
|
+
import { basename as basename2, join as join2, relative, resolve } from "node:path";
|
|
1820
2095
|
var TEST_FILES = ["visualautomate.test.json", "test.json"];
|
|
1821
2096
|
var ENTRIES = ["plugin.ts", "plugin.js", "index.ts", "index.js"];
|
|
1822
2097
|
function entryIn(dir) {
|
|
1823
|
-
return ENTRIES.map((file2) =>
|
|
2098
|
+
return ENTRIES.map((file2) => join2(dir, file2)).find((file2) => existsSync2(file2)) ?? null;
|
|
1824
2099
|
}
|
|
1825
2100
|
function matchFolders(folders, name) {
|
|
1826
|
-
const
|
|
1827
|
-
const exact = folders.filter((folder) => folder.toLowerCase() ===
|
|
2101
|
+
const wanted2 = name.trim().toLowerCase();
|
|
2102
|
+
const exact = folders.filter((folder) => folder.toLowerCase() === wanted2);
|
|
1828
2103
|
if (exact.length > 0) return exact;
|
|
1829
|
-
const prefix = folders.filter((folder) => folder.toLowerCase().startsWith(
|
|
2104
|
+
const prefix = folders.filter((folder) => folder.toLowerCase().startsWith(wanted2));
|
|
1830
2105
|
if (prefix.length > 0) return prefix;
|
|
1831
|
-
return folders.filter((folder) => folder.toLowerCase().includes(
|
|
2106
|
+
return folders.filter((folder) => folder.toLowerCase().includes(wanted2));
|
|
1832
2107
|
}
|
|
1833
2108
|
function findTargets(cwd, only) {
|
|
1834
2109
|
const dir = resolve(cwd);
|
|
1835
|
-
const modulesDir =
|
|
1836
|
-
if (!entryIn(dir) &&
|
|
1837
|
-
const folders = readdirSync(modulesDir, { withFileTypes: true }).filter((d) => d.isDirectory() &&
|
|
2110
|
+
const modulesDir = join2(dir, "modules");
|
|
2111
|
+
if (!entryIn(dir) && existsSync2(modulesDir) && statSync(modulesDir).isDirectory()) {
|
|
2112
|
+
const folders = readdirSync(modulesDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync2(join2(modulesDir, d.name, "manifest.json"))).map((d) => d.name).sort();
|
|
1838
2113
|
if (folders.length === 0) {
|
|
1839
2114
|
throw new Error("There is a modules/ folder here, but none of its folders has a manifest.json.");
|
|
1840
2115
|
}
|
|
@@ -1842,9 +2117,9 @@ function findTargets(cwd, only) {
|
|
|
1842
2117
|
if (only && chosen.length === 0) {
|
|
1843
2118
|
throw new Error(`No module matches "${only}". This repository has: ${folders.join(", ")}.`);
|
|
1844
2119
|
}
|
|
1845
|
-
return chosen.map((folder) => targetIn(
|
|
2120
|
+
return chosen.map((folder) => targetIn(join2(modulesDir, folder), `modules/${folder}`)).filter(Boolean);
|
|
1846
2121
|
}
|
|
1847
|
-
const target = targetIn(dir,
|
|
2122
|
+
const target = targetIn(dir, basename2(dir));
|
|
1848
2123
|
if (!target) {
|
|
1849
2124
|
throw new Error(
|
|
1850
2125
|
"Nothing to test here: no plugin.ts, plugin.js or index.js, and no modules/ folder.\nRun this in a module's folder, at the root of its repository, or run `vsa init <name>`."
|
|
@@ -1855,7 +2130,7 @@ function findTargets(cwd, only) {
|
|
|
1855
2130
|
function targetIn(dir, name) {
|
|
1856
2131
|
const entry = entryIn(dir);
|
|
1857
2132
|
if (!entry) return null;
|
|
1858
|
-
const raw =
|
|
2133
|
+
const raw = basename2(entry) === "index.js" && !existsSync2(join2(dir, "plugin.json"));
|
|
1859
2134
|
return { name, dir, entry, raw };
|
|
1860
2135
|
}
|
|
1861
2136
|
function targetForPath(targets, changed) {
|
|
@@ -1869,9 +2144,9 @@ function isPlainObject(value) {
|
|
|
1869
2144
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1870
2145
|
}
|
|
1871
2146
|
async function readTestFile(dir, override) {
|
|
1872
|
-
const path = override ? resolve(override) : TEST_FILES.map((f) =>
|
|
2147
|
+
const path = override ? resolve(override) : TEST_FILES.map((f) => join2(dir, f)).find((f) => existsSync2(f)) ?? null;
|
|
1873
2148
|
if (!path) return { path: null, config: {}, input: {} };
|
|
1874
|
-
if (!
|
|
2149
|
+
if (!existsSync2(path)) throw new Error(`${path} does not exist.`);
|
|
1875
2150
|
let raw;
|
|
1876
2151
|
try {
|
|
1877
2152
|
raw = JSON.parse(await readFile(path, "utf8"));
|
|
@@ -2023,12 +2298,12 @@ function guardFetch(policy, base, onDenied) {
|
|
|
2023
2298
|
}
|
|
2024
2299
|
|
|
2025
2300
|
// src/persist.ts
|
|
2026
|
-
import { existsSync as
|
|
2301
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
2027
2302
|
import { mkdir, readdir, readFile as readFile2, rm, writeFile } from "node:fs/promises";
|
|
2028
|
-
import { dirname, join as
|
|
2303
|
+
import { dirname as dirname2, join as join3, relative as relative2, resolve as resolve2, sep } from "node:path";
|
|
2029
2304
|
var PERSIST_DIR = ".visualautomate";
|
|
2030
2305
|
function persistEnabled(dir, flag) {
|
|
2031
|
-
return flag === "true" ||
|
|
2306
|
+
return flag === "true" || existsSync3(join3(dir, PERSIST_DIR));
|
|
2032
2307
|
}
|
|
2033
2308
|
function diskPathFor(storageRoot, path) {
|
|
2034
2309
|
if (!path || path.includes("\0")) return null;
|
|
@@ -2038,22 +2313,22 @@ function diskPathFor(storageRoot, path) {
|
|
|
2038
2313
|
return target;
|
|
2039
2314
|
}
|
|
2040
2315
|
async function walk(root, dir = root) {
|
|
2041
|
-
if (!
|
|
2316
|
+
if (!existsSync3(dir)) return [];
|
|
2042
2317
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
2043
2318
|
const nested = await Promise.all(
|
|
2044
|
-
entries.map((entry) => entry.isDirectory() ? walk(root,
|
|
2319
|
+
entries.map((entry) => entry.isDirectory() ? walk(root, join3(dir, entry.name)) : Promise.resolve([join3(dir, entry.name)]))
|
|
2045
2320
|
);
|
|
2046
2321
|
return nested.flat();
|
|
2047
2322
|
}
|
|
2048
2323
|
async function loadPersisted(dir) {
|
|
2049
|
-
const storageRoot =
|
|
2324
|
+
const storageRoot = join3(dir, PERSIST_DIR, "storage");
|
|
2050
2325
|
const storage = {};
|
|
2051
2326
|
for (const file2 of await walk(storageRoot)) {
|
|
2052
2327
|
storage[relative2(storageRoot, file2).split(sep).join("/")] = new Uint8Array(await readFile2(file2));
|
|
2053
2328
|
}
|
|
2054
2329
|
let state = {};
|
|
2055
|
-
const statePath =
|
|
2056
|
-
if (
|
|
2330
|
+
const statePath = join3(dir, PERSIST_DIR, "state.json");
|
|
2331
|
+
if (existsSync3(statePath)) {
|
|
2057
2332
|
try {
|
|
2058
2333
|
const parsed = JSON.parse(await readFile2(statePath, "utf8"));
|
|
2059
2334
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) state = parsed;
|
|
@@ -2064,7 +2339,7 @@ async function loadPersisted(dir) {
|
|
|
2064
2339
|
return { storage, state };
|
|
2065
2340
|
}
|
|
2066
2341
|
async function savePersisted(dir, files, state) {
|
|
2067
|
-
const storageRoot =
|
|
2342
|
+
const storageRoot = join3(dir, PERSIST_DIR, "storage");
|
|
2068
2343
|
const refused = [];
|
|
2069
2344
|
const keep = /* @__PURE__ */ new Set();
|
|
2070
2345
|
for (const [path, file2] of files) {
|
|
@@ -2074,19 +2349,51 @@ async function savePersisted(dir, files, state) {
|
|
|
2074
2349
|
continue;
|
|
2075
2350
|
}
|
|
2076
2351
|
keep.add(target);
|
|
2077
|
-
await mkdir(
|
|
2352
|
+
await mkdir(dirname2(target), { recursive: true });
|
|
2078
2353
|
await writeFile(target, file2.bytes);
|
|
2079
2354
|
}
|
|
2080
2355
|
for (const existing of await walk(storageRoot)) {
|
|
2081
2356
|
if (!keep.has(existing)) await rm(existing);
|
|
2082
2357
|
}
|
|
2083
|
-
await mkdir(
|
|
2084
|
-
await writeFile(
|
|
2358
|
+
await mkdir(join3(dir, PERSIST_DIR), { recursive: true });
|
|
2359
|
+
await writeFile(join3(dir, PERSIST_DIR, "state.json"), JSON.stringify(Object.fromEntries(state), null, 2) + "\n");
|
|
2085
2360
|
return refused;
|
|
2086
2361
|
}
|
|
2087
2362
|
|
|
2088
2363
|
// src/docker.ts
|
|
2089
2364
|
import { spawnSync } from "node:child_process";
|
|
2365
|
+
|
|
2366
|
+
// src/colour.ts
|
|
2367
|
+
var CODES = {
|
|
2368
|
+
reset: "\x1B[0m",
|
|
2369
|
+
bold: "\x1B[1m",
|
|
2370
|
+
dim: "\x1B[2m",
|
|
2371
|
+
red: "\x1B[31m",
|
|
2372
|
+
green: "\x1B[32m",
|
|
2373
|
+
yellow: "\x1B[33m",
|
|
2374
|
+
blue: "\x1B[34m"
|
|
2375
|
+
};
|
|
2376
|
+
function wanted() {
|
|
2377
|
+
const env = process.env;
|
|
2378
|
+
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
2379
|
+
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0") return true;
|
|
2380
|
+
return Boolean(process.stdout.isTTY);
|
|
2381
|
+
}
|
|
2382
|
+
var ON = wanted();
|
|
2383
|
+
function paint(code) {
|
|
2384
|
+
return (text) => ON ? `${code}${text}${CODES.reset}` : text;
|
|
2385
|
+
}
|
|
2386
|
+
var red = paint(CODES.red);
|
|
2387
|
+
var green = paint(CODES.green);
|
|
2388
|
+
var yellow = paint(CODES.yellow);
|
|
2389
|
+
var blue = paint(CODES.blue);
|
|
2390
|
+
var dim = paint(CODES.dim);
|
|
2391
|
+
var bold = paint(CODES.bold);
|
|
2392
|
+
function colourEnv() {
|
|
2393
|
+
return ON ? ["--env", "FORCE_COLOR=1"] : ["--env", "NO_COLOR=1"];
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
// src/docker.ts
|
|
2090
2397
|
var IMAGE = "ghcr.io/visualautomate/plugin-sandbox";
|
|
2091
2398
|
var TIERS = {
|
|
2092
2399
|
standard: { cpus: "0.25", memory: "1g" },
|
|
@@ -2119,6 +2426,9 @@ function dockerArgs(run) {
|
|
|
2119
2426
|
"ALL",
|
|
2120
2427
|
"--security-opt",
|
|
2121
2428
|
"no-new-privileges",
|
|
2429
|
+
// So a run inside the container is as readable as one out here: its
|
|
2430
|
+
// stdout is a pipe to Docker, which would otherwise turn colour off.
|
|
2431
|
+
...colourEnv(),
|
|
2122
2432
|
...run.user ? ["--user", `${run.user.uid}:${run.user.gid}`] : [],
|
|
2123
2433
|
run.image,
|
|
2124
2434
|
...run.args
|
|
@@ -2142,16 +2452,18 @@ function dockerProblem() {
|
|
|
2142
2452
|
function explainExit(code, tier) {
|
|
2143
2453
|
if (code === 137) {
|
|
2144
2454
|
const next = Object.keys(TIERS)[Object.keys(TIERS).indexOf(tier) + 1];
|
|
2145
|
-
return
|
|
2455
|
+
return red(
|
|
2456
|
+
`\u2717 stopped the step used more than the ${TIERS[tier].memory.replace("g", " GB")} of memory the ${tier} tier has, and the container was killed. On the platform it would stop the same way.` + (next ? ` To check it fits a bigger machine: --tier ${next}` : "")
|
|
2457
|
+
);
|
|
2146
2458
|
}
|
|
2147
|
-
if (code === 125) return "\u2717 Docker could not start the container. Is the image available? Try: docker pull " + IMAGE;
|
|
2459
|
+
if (code === 125) return red("\u2717 Docker could not start the container. Is the image available? Try: docker pull " + IMAGE);
|
|
2148
2460
|
return null;
|
|
2149
2461
|
}
|
|
2150
2462
|
function ensureDocker(say, waitMs = 12e4) {
|
|
2151
2463
|
const problem = dockerProblem();
|
|
2152
2464
|
if (!problem) return null;
|
|
2153
2465
|
if (problem.includes("not installed")) return problem;
|
|
2154
|
-
say("Docker is not running. Starting Docker Desktop\u2026");
|
|
2466
|
+
say(yellow("Docker is not running. Starting Docker Desktop\u2026"));
|
|
2155
2467
|
const started = spawnSync("docker", ["desktop", "start"], { encoding: "utf8" });
|
|
2156
2468
|
if (started.status !== 0) {
|
|
2157
2469
|
say("Could not start it from here \u2014 start Docker Desktop yourself; this will carry on when it is up.");
|
|
@@ -2185,34 +2497,9 @@ function runInDocker(run) {
|
|
|
2185
2497
|
}
|
|
2186
2498
|
|
|
2187
2499
|
// src/index.ts
|
|
2188
|
-
var VERSION = true ? "0.
|
|
2189
|
-
var DEFAULT_API_URL = "https://visualautomate.com";
|
|
2190
|
-
function configPath() {
|
|
2191
|
-
return join3(homedir(), ".visualautomate", "config.json");
|
|
2192
|
-
}
|
|
2193
|
-
async function readConfig() {
|
|
2194
|
-
try {
|
|
2195
|
-
return JSON.parse(await readFile3(configPath(), "utf8"));
|
|
2196
|
-
} catch {
|
|
2197
|
-
return {};
|
|
2198
|
-
}
|
|
2199
|
-
}
|
|
2200
|
-
async function writeConfig(next) {
|
|
2201
|
-
const path = configPath();
|
|
2202
|
-
await mkdir2(dirname2(path), { recursive: true });
|
|
2203
|
-
await writeFile2(path, JSON.stringify(next, null, 2) + "\n", { mode: 384 });
|
|
2204
|
-
}
|
|
2205
|
-
async function tokenFor(flags) {
|
|
2206
|
-
const stored = await readConfig();
|
|
2207
|
-
const apiUrl = (flags["api-url"] || process.env.VISUALAUTOMATE_API_URL || stored.apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
2208
|
-
const token = flags.token || process.env.VISUALAUTOMATE_API_TOKEN || stored.token;
|
|
2209
|
-
if (!token) {
|
|
2210
|
-
fail("Not signed in. Run `visualautomate login` first.");
|
|
2211
|
-
}
|
|
2212
|
-
return { token, apiUrl };
|
|
2213
|
-
}
|
|
2500
|
+
var VERSION = true ? "0.6.0" : "0.0.0-dev";
|
|
2214
2501
|
function fail(message) {
|
|
2215
|
-
console.error(message);
|
|
2502
|
+
console.error(red(message));
|
|
2216
2503
|
throw new SilentExit();
|
|
2217
2504
|
}
|
|
2218
2505
|
var SilentExit = class extends Error {
|
|
@@ -2224,97 +2511,6 @@ var SilentExit = class extends Error {
|
|
|
2224
2511
|
function ok(message) {
|
|
2225
2512
|
console.log(message);
|
|
2226
2513
|
}
|
|
2227
|
-
function openBrowser(url) {
|
|
2228
|
-
const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
2229
|
-
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
2230
|
-
try {
|
|
2231
|
-
spawn(command, args, { stdio: "ignore", detached: true }).unref();
|
|
2232
|
-
} catch {
|
|
2233
|
-
}
|
|
2234
|
-
}
|
|
2235
|
-
async function cmdLogin(flags) {
|
|
2236
|
-
const stored = await readConfig();
|
|
2237
|
-
const apiUrl = (flags["api-url"] || process.env.VISUALAUTOMATE_API_URL || stored.apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
2238
|
-
const state = randomBytes(24).toString("base64url");
|
|
2239
|
-
const token = await new Promise((resolveToken, rejectToken) => {
|
|
2240
|
-
const server = createServer((req, res) => {
|
|
2241
|
-
if (!req.url?.startsWith("/callback") || req.method !== "POST") {
|
|
2242
|
-
res.writeHead(404).end("Not found");
|
|
2243
|
-
return;
|
|
2244
|
-
}
|
|
2245
|
-
let body = "";
|
|
2246
|
-
req.on("data", (chunk) => {
|
|
2247
|
-
body += chunk;
|
|
2248
|
-
if (body.length > 8192) req.destroy();
|
|
2249
|
-
});
|
|
2250
|
-
req.on("end", () => {
|
|
2251
|
-
const fields = new URLSearchParams(body);
|
|
2252
|
-
const answer = (status, text) => {
|
|
2253
|
-
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
2254
|
-
res.end(
|
|
2255
|
-
`<!doctype html><meta charset="utf-8"><title>VisualAutomate</title><body style="font:16px system-ui;padding:3rem;max-width:32rem;margin:auto">${text}</body>`
|
|
2256
|
-
);
|
|
2257
|
-
};
|
|
2258
|
-
if (fields.get("state") !== state) {
|
|
2259
|
-
answer(400, "<h1>That did not match</h1><p>Run <code>visualautomate login</code> again.</p>");
|
|
2260
|
-
return;
|
|
2261
|
-
}
|
|
2262
|
-
const received = fields.get("token");
|
|
2263
|
-
if (!received) {
|
|
2264
|
-
answer(400, "<h1>No token in the reply</h1><p>Run <code>visualautomate login</code> again.</p>");
|
|
2265
|
-
return;
|
|
2266
|
-
}
|
|
2267
|
-
answer(200, "<h1>Signed in</h1><p>You can close this tab and go back to your terminal.</p>");
|
|
2268
|
-
server.close();
|
|
2269
|
-
resolveToken(received);
|
|
2270
|
-
});
|
|
2271
|
-
});
|
|
2272
|
-
server.listen(0, "127.0.0.1", () => {
|
|
2273
|
-
const address = server.address();
|
|
2274
|
-
if (!address || typeof address === "string") {
|
|
2275
|
-
rejectToken(new Error("Could not open a port to listen on"));
|
|
2276
|
-
return;
|
|
2277
|
-
}
|
|
2278
|
-
const url = `${apiUrl}/cli/login?port=${address.port}&state=${state}`;
|
|
2279
|
-
ok(`Opening ${url}
|
|
2280
|
-
`);
|
|
2281
|
-
ok("If your browser did not open, paste that address into it.");
|
|
2282
|
-
openBrowser(url);
|
|
2283
|
-
});
|
|
2284
|
-
server.on("error", rejectToken);
|
|
2285
|
-
setTimeout(() => {
|
|
2286
|
-
server.close();
|
|
2287
|
-
rejectToken(new Error("Timed out after five minutes waiting for the browser."));
|
|
2288
|
-
}, 5 * 6e4).unref();
|
|
2289
|
-
});
|
|
2290
|
-
const who = await whoami(apiUrl, token);
|
|
2291
|
-
await writeConfig({ token, apiUrl, email: who.email });
|
|
2292
|
-
ok(`
|
|
2293
|
-
\u2713 Signed in as ${who.email}`);
|
|
2294
|
-
ok(` Token stored in ${configPath()}`);
|
|
2295
|
-
}
|
|
2296
|
-
async function whoami(apiUrl, token) {
|
|
2297
|
-
const res = await fetch(`${apiUrl}/api/cli/whoami`, {
|
|
2298
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
2299
|
-
});
|
|
2300
|
-
if (res.status === 401) fail("That token is not valid. Run `visualautomate login` again.");
|
|
2301
|
-
if (!res.ok) fail(`Could not reach ${apiUrl} (status ${res.status}).`);
|
|
2302
|
-
const data = await res.json();
|
|
2303
|
-
return { email: data.email ?? "(unknown)", developer: Boolean(data.pluginDeveloper) };
|
|
2304
|
-
}
|
|
2305
|
-
async function cmdWhoami(flags) {
|
|
2306
|
-
const { token, apiUrl } = await tokenFor(flags);
|
|
2307
|
-
const who = await whoami(apiUrl, token);
|
|
2308
|
-
ok(`${who.email} on ${apiUrl}`);
|
|
2309
|
-
if (!who.developer) {
|
|
2310
|
-
ok("\nThis account is not a plugin developer yet, so `push` will be refused.");
|
|
2311
|
-
ok("Turn it on from the dashboard under Developer settings.");
|
|
2312
|
-
}
|
|
2313
|
-
}
|
|
2314
|
-
async function cmdLogout() {
|
|
2315
|
-
await writeConfig({});
|
|
2316
|
-
ok("Signed out. The token file is empty.");
|
|
2317
|
-
}
|
|
2318
2514
|
async function cmdInit(args, flags) {
|
|
2319
2515
|
const name = args[0];
|
|
2320
2516
|
if (!name) fail("Usage: visualautomate init <name> [--template <name>]");
|
|
@@ -2324,22 +2520,22 @@ async function cmdInit(args, flags) {
|
|
|
2324
2520
|
fail(`Unknown template "${templateName}". Available: ${Object.keys(TEMPLATES).join(", ")}`);
|
|
2325
2521
|
}
|
|
2326
2522
|
const target = resolve3(process.cwd(), name);
|
|
2327
|
-
if (
|
|
2523
|
+
if (existsSync4(target)) fail(`"${name}" already exists here.`);
|
|
2328
2524
|
const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
2329
2525
|
await mkdir2(target, { recursive: true });
|
|
2330
|
-
await writeFile2(
|
|
2331
|
-
await writeFile2(
|
|
2526
|
+
await writeFile2(join4(target, "plugin.ts"), template.code);
|
|
2527
|
+
await writeFile2(join4(target, "manifest.json"), template.manifest);
|
|
2332
2528
|
await writeFile2(
|
|
2333
|
-
|
|
2529
|
+
join4(target, "plugin.json"),
|
|
2334
2530
|
JSON.stringify(
|
|
2335
2531
|
{ name: slug, version: "1.0.0", description: template.description, category: template.category },
|
|
2336
2532
|
null,
|
|
2337
2533
|
2
|
|
2338
2534
|
) + "\n"
|
|
2339
2535
|
);
|
|
2340
|
-
await writeFile2(
|
|
2536
|
+
await writeFile2(join4(target, "visualautomate.d.ts"), TYPE_DECLARATIONS);
|
|
2341
2537
|
await writeFile2(
|
|
2342
|
-
|
|
2538
|
+
join4(target, "tsconfig.json"),
|
|
2343
2539
|
JSON.stringify(
|
|
2344
2540
|
{
|
|
2345
2541
|
compilerOptions: {
|
|
@@ -2358,11 +2554,11 @@ async function cmdInit(args, flags) {
|
|
|
2358
2554
|
) + "\n"
|
|
2359
2555
|
);
|
|
2360
2556
|
await writeFile2(
|
|
2361
|
-
|
|
2557
|
+
join4(target, "test.json"),
|
|
2362
2558
|
JSON.stringify(template.testInput, null, 2) + "\n"
|
|
2363
2559
|
);
|
|
2364
2560
|
await writeFile2(
|
|
2365
|
-
|
|
2561
|
+
join4(target, "README.md"),
|
|
2366
2562
|
`# ${name}
|
|
2367
2563
|
|
|
2368
2564
|
${template.description}
|
|
@@ -2376,9 +2572,9 @@ visualautomate push # publish it to your account
|
|
|
2376
2572
|
\`test.json\` is the input, config and state \`test\` runs with.
|
|
2377
2573
|
`
|
|
2378
2574
|
);
|
|
2379
|
-
ok(`
|
|
2575
|
+
ok(green(`
|
|
2380
2576
|
\u2713 Created ${name} from the "${templateName}" template
|
|
2381
|
-
`);
|
|
2577
|
+
`));
|
|
2382
2578
|
ok(" cd " + name);
|
|
2383
2579
|
ok(" visualautomate test");
|
|
2384
2580
|
ok(" visualautomate push\n");
|
|
@@ -2412,12 +2608,12 @@ function line(indent, text) {
|
|
|
2412
2608
|
async function runTarget(target, flags, several) {
|
|
2413
2609
|
const say = (text) => line(several, text);
|
|
2414
2610
|
if (several) ok(`
|
|
2415
|
-
|
|
2611
|
+
${bold(`\u25B8 ${target.name}`)}`);
|
|
2416
2612
|
try {
|
|
2417
2613
|
const code = await codeFor(target);
|
|
2418
2614
|
const check = validatePluginCode(code);
|
|
2419
2615
|
if (!check.valid) {
|
|
2420
|
-
say(
|
|
2616
|
+
say(`${red("\u2717 refused")} ${check.error}`);
|
|
2421
2617
|
return false;
|
|
2422
2618
|
}
|
|
2423
2619
|
const settings = await readTestFile(target.dir, flags.input);
|
|
@@ -2425,13 +2621,13 @@ async function runTarget(target, flags, several) {
|
|
|
2425
2621
|
const exported = loadPluginCode(code, sandboxRequire(packageLoader(target.dir)));
|
|
2426
2622
|
const plugin = typeof exported === "function" ? exported : typeof exported?.execute === "function" ? exported : exported?.default;
|
|
2427
2623
|
let outputs = [];
|
|
2428
|
-
const manifestPath =
|
|
2429
|
-
if (
|
|
2624
|
+
const manifestPath = join4(target.dir, "manifest.json");
|
|
2625
|
+
if (existsSync4(manifestPath)) {
|
|
2430
2626
|
try {
|
|
2431
2627
|
const manifest = parseJsonc(await readFile3(manifestPath, "utf8")).value;
|
|
2432
2628
|
outputs = manifest.outputs ?? [];
|
|
2433
2629
|
} catch (error) {
|
|
2434
|
-
say(`\u2717 manifest.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
2630
|
+
say(red(`\u2717 manifest.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`));
|
|
2435
2631
|
return false;
|
|
2436
2632
|
}
|
|
2437
2633
|
}
|
|
@@ -2457,33 +2653,37 @@ async function runTarget(target, flags, several) {
|
|
|
2457
2653
|
const port = result.output?.output ?? null;
|
|
2458
2654
|
const expected = settings.expectOutput;
|
|
2459
2655
|
const passed = result.status === "success" && (!expected || port === expected);
|
|
2460
|
-
|
|
2461
|
-
|
|
2656
|
+
const verdict = passed ? green(bold("\u2713 passed")) : red(bold("\u2717 failed"));
|
|
2657
|
+
say(`${verdict} ${dim(`${result.status}, ${result.durationMs}ms`)}`);
|
|
2658
|
+
if (result.error) say(`${dim("error")} ${red(result.error)}`);
|
|
2462
2659
|
if (result.output) {
|
|
2463
|
-
|
|
2464
|
-
say(
|
|
2660
|
+
const port_ = passed || !expected ? port : red(String(port));
|
|
2661
|
+
say(`${dim("output")} ${port_}${expected ? dim(` (expected ${expected})`) : ""}`);
|
|
2662
|
+
say(`${dim("data")} ${JSON.stringify(result.output.data, null, 2).replace(/\n/g, "\n ")}`);
|
|
2465
2663
|
}
|
|
2466
|
-
if (result.files.size > 0) say(
|
|
2467
|
-
if (result.state.size > 0) say(
|
|
2468
|
-
for (const entry of result.logs ?? []) say(
|
|
2664
|
+
if (result.files.size > 0) say(`${dim("files")} ${[...result.files.keys()].join(", ")}`);
|
|
2665
|
+
if (result.state.size > 0) say(`${dim("state")} ${JSON.stringify(Object.fromEntries(result.state))}`);
|
|
2666
|
+
for (const entry of result.logs ?? []) say(`${dim("log")} ${entry}`);
|
|
2469
2667
|
for (const [host, enforced] of denied) {
|
|
2470
2668
|
say(
|
|
2471
|
-
enforced ?
|
|
2669
|
+
enforced ? `${dim("egress")} ${red(`blocked ${host}`)} \u2014 not in allowedDomains` : yellow(`! egress ${host} is not allowed on the platform unless the plugin declares it. `) + `Add "allowedDomains": ["${host}"] to the test file to check with the same rule.`
|
|
2472
2670
|
);
|
|
2473
2671
|
}
|
|
2474
2672
|
if (persist) {
|
|
2475
2673
|
const refused = await savePersisted(target.dir, result.files, result.state);
|
|
2476
|
-
say(
|
|
2477
|
-
for (const path of refused) say(`! storage path "${path}" points outside the storage folder and was not written`);
|
|
2674
|
+
say(`${dim("kept")} ${dim(`storage and state in ${relative3(process.cwd(), join4(target.dir, ".visualautomate")) || ".visualautomate"}`)}`);
|
|
2675
|
+
for (const path of refused) say(yellow(`! storage path "${path}" points outside the storage folder and was not written`));
|
|
2478
2676
|
}
|
|
2479
2677
|
if (port && outputs.length > 0 && !outputs.includes(port)) {
|
|
2480
2678
|
say(
|
|
2481
|
-
|
|
2679
|
+
yellow(
|
|
2680
|
+
`! This returned output "${port}", which manifest.json does not list (it has ${outputs.map((o) => `"${o}"`).join(", ")}). On a canvas nothing would follow it.`
|
|
2681
|
+
)
|
|
2482
2682
|
);
|
|
2483
2683
|
}
|
|
2484
2684
|
return passed;
|
|
2485
2685
|
} catch (error) {
|
|
2486
|
-
say(`\u2717 ${error instanceof Error ? error.message : String(error)}`);
|
|
2686
|
+
say(red(`\u2717 ${error instanceof Error ? error.message : String(error)}`));
|
|
2487
2687
|
return false;
|
|
2488
2688
|
}
|
|
2489
2689
|
}
|
|
@@ -2492,14 +2692,17 @@ async function runTargets(targets, flags) {
|
|
|
2492
2692
|
for (const target of targets) {
|
|
2493
2693
|
if (await runTarget(target, flags, targets.length > 1)) passed++;
|
|
2494
2694
|
}
|
|
2495
|
-
if (targets.length > 1)
|
|
2496
|
-
|
|
2695
|
+
if (targets.length > 1) {
|
|
2696
|
+
const tally = `${passed} of ${targets.length} passed`;
|
|
2697
|
+
ok(`
|
|
2698
|
+
${passed === targets.length ? green(bold(tally)) : red(bold(tally))}`);
|
|
2699
|
+
}
|
|
2497
2700
|
return passed === targets.length;
|
|
2498
2701
|
}
|
|
2499
2702
|
function packageLoader(dir) {
|
|
2500
|
-
const fromProject = createRequire(
|
|
2703
|
+
const fromProject = createRequire(join4(dir, "noop.js"));
|
|
2501
2704
|
const imageDir = process.env.VISUALAUTOMATE_PACKAGES;
|
|
2502
|
-
const fromImage = imageDir ? createRequire(
|
|
2705
|
+
const fromImage = imageDir ? createRequire(join4(imageDir, "noop.js")) : null;
|
|
2503
2706
|
return (name) => {
|
|
2504
2707
|
try {
|
|
2505
2708
|
return fromProject(name);
|
|
@@ -2559,28 +2762,103 @@ function sayWhatRuns(targets, picked) {
|
|
|
2559
2762
|
return;
|
|
2560
2763
|
}
|
|
2561
2764
|
ok(`Running ${targets.length} modules: ${targets.map((t) => t.name.replace(/^modules\//, "")).join(", ")}`);
|
|
2562
|
-
if (!picked) ok(`One of them: vsa dev ${targets[0].name.replace(/^modules\//, "")}`);
|
|
2765
|
+
if (!picked) ok(dim(`One of them: vsa dev ${targets[0].name.replace(/^modules\//, "")}`));
|
|
2766
|
+
}
|
|
2767
|
+
function keepTypes() {
|
|
2768
|
+
try {
|
|
2769
|
+
return ensureRepoTypes(projectRoot(process.cwd()), VERSION);
|
|
2770
|
+
} catch {
|
|
2771
|
+
return [];
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
async function cmdTypes() {
|
|
2775
|
+
const root = projectRoot(process.cwd());
|
|
2776
|
+
const written = ensureRepoTypes(root, VERSION);
|
|
2777
|
+
if (written.length === 0) {
|
|
2778
|
+
ok(`${TYPES_FILE} and ${JSCONFIG_FILE} are already what this CLI writes.`);
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
ok(green(`\u2713 Wrote ${written.join(" and ")}`));
|
|
2782
|
+
ok(dim("Your editor now completes input, config and context. Name the type above module.exports:"));
|
|
2783
|
+
ok(dim(" /** @type {PluginModule} */"));
|
|
2563
2784
|
}
|
|
2564
2785
|
async function cmdTest(args, flags) {
|
|
2565
2786
|
const targets = targetsOrFail(args, flags);
|
|
2787
|
+
keepTypes();
|
|
2566
2788
|
if (flags.docker) prepareSandbox(flags);
|
|
2567
2789
|
const passed = flags.docker ? runTargetsInDocker(targets, targets, flags) : await runTargets(targets, flags);
|
|
2568
2790
|
if (!passed) process.exitCode = 1;
|
|
2569
2791
|
}
|
|
2792
|
+
async function cmdAddProperty(args, flags) {
|
|
2793
|
+
const [type, name, description, value] = args;
|
|
2794
|
+
if (!type || !name) {
|
|
2795
|
+
fail(
|
|
2796
|
+
`What property? vsa add:property <type> <name> [description] [default]
|
|
2797
|
+
vsa add:property string apiKey "The key to call with"
|
|
2798
|
+
vsa add:property number retries "How many times to try again" 2
|
|
2799
|
+
Types: ${PROPERTY_TYPES.join(", ")}`
|
|
2800
|
+
);
|
|
2801
|
+
}
|
|
2802
|
+
if (!isPropertyType(type)) {
|
|
2803
|
+
fail(`There is no "${type}" property. Types: ${PROPERTY_TYPES.join(", ")}`);
|
|
2804
|
+
}
|
|
2805
|
+
if (!isPropertyName(name)) {
|
|
2806
|
+
fail(`"${name}" is not a name your code can read: config.${name} has to be something you can write.`);
|
|
2807
|
+
}
|
|
2808
|
+
const targets = targetsOrFail([], flags);
|
|
2809
|
+
if (targets.length > 1) {
|
|
2810
|
+
const names2 = targets.map((t) => t.name.replace(/^modules\//, ""));
|
|
2811
|
+
fail(
|
|
2812
|
+
`Which module? This repository has ${names2.join(", ")}.
|
|
2813
|
+
vsa add:property ${type} ${name} --module ${names2[0]}`
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
const target = targets[0];
|
|
2817
|
+
const file2 = join4(target.dir, "manifest.json");
|
|
2818
|
+
if (!existsSync4(file2)) fail(`${target.name} has no manifest.json.`);
|
|
2819
|
+
const text = await readFile3(file2, "utf8");
|
|
2820
|
+
if (hasProperty(text, name)) {
|
|
2821
|
+
fail(`${target.name} already has a property called ${name}. Change it in ${target.name}/manifest.json.`);
|
|
2822
|
+
}
|
|
2823
|
+
let next;
|
|
2824
|
+
try {
|
|
2825
|
+
next = withProperty(
|
|
2826
|
+
text,
|
|
2827
|
+
name,
|
|
2828
|
+
buildProperty({
|
|
2829
|
+
type,
|
|
2830
|
+
name,
|
|
2831
|
+
description,
|
|
2832
|
+
value,
|
|
2833
|
+
label: flags.label,
|
|
2834
|
+
required: flags.required === "true",
|
|
2835
|
+
options: flags.options,
|
|
2836
|
+
provider: flags.provider
|
|
2837
|
+
})
|
|
2838
|
+
);
|
|
2839
|
+
} catch (error) {
|
|
2840
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
2841
|
+
}
|
|
2842
|
+
await writeFile2(file2, next, "utf8");
|
|
2843
|
+
ok(green(`\u2713 Added ${name} to ${target.name}`));
|
|
2844
|
+
ok(dim(` Read it in your code as config.${name}`));
|
|
2845
|
+
}
|
|
2570
2846
|
async function cmdDev(args, flags) {
|
|
2571
2847
|
const root = process.cwd();
|
|
2572
2848
|
const picked = args[0] ?? flags.module;
|
|
2573
2849
|
let targets = targetsOrFail(args, flags);
|
|
2850
|
+
const typesWritten = keepTypes();
|
|
2574
2851
|
const inSandbox = flags.local !== "true";
|
|
2575
2852
|
if (inSandbox) prepareSandbox(flags);
|
|
2576
2853
|
const header = () => {
|
|
2577
2854
|
process.stdout.write("\x1Bc");
|
|
2578
|
-
ok(
|
|
2855
|
+
ok(`${bold("vsa dev")} ${dim(`\u2014 ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \u2014 Ctrl+C to stop`)}`);
|
|
2579
2856
|
};
|
|
2580
2857
|
const run = async (chosen) => inSandbox ? runTargetsInDocker(chosen, targets, flags) : runTargets(chosen, flags);
|
|
2581
2858
|
header();
|
|
2582
|
-
ok(inSandbox ? `in ${imageFor(flags)}, tier ${flags.tier || "standard"}` : "on this machine, not in the sandbox");
|
|
2859
|
+
ok(dim(inSandbox ? `in ${imageFor(flags)}, tier ${flags.tier || "standard"}` : "on this machine, not in the sandbox"));
|
|
2583
2860
|
sayWhatRuns(targets, Boolean(picked));
|
|
2861
|
+
if (typesWritten.length > 0) ok(dim(`Wrote ${typesWritten.join(" and ")} \u2014 your editor now knows what context is`));
|
|
2584
2862
|
await run(targets);
|
|
2585
2863
|
const ignored = [`${sep2}node_modules${sep2}`, `${sep2}.git${sep2}`, `${sep2}dist${sep2}`, `${sep2}.visualautomate${sep2}`];
|
|
2586
2864
|
const pending = /* @__PURE__ */ new Set();
|
|
@@ -2598,20 +2876,20 @@ async function cmdDev(args, flags) {
|
|
|
2598
2876
|
targets = findTargets(root, picked);
|
|
2599
2877
|
} catch (error) {
|
|
2600
2878
|
header();
|
|
2601
|
-
ok(`\u2717 ${error instanceof Error ? error.message : String(error)}`);
|
|
2879
|
+
ok(red(`\u2717 ${error instanceof Error ? error.message : String(error)}`));
|
|
2602
2880
|
running = false;
|
|
2603
2881
|
return;
|
|
2604
2882
|
}
|
|
2605
2883
|
const hit = changed.map((path) => targetForPath(targets, path));
|
|
2606
2884
|
const chosen = hit.some((t) => t === null) ? targets : [...new Set(hit)];
|
|
2607
2885
|
header();
|
|
2608
|
-
ok(`changed: ${changed.map((p) => relative3(root, p)).join(", ")}`);
|
|
2886
|
+
ok(dim(`changed: ${changed.map((p) => relative3(root, p)).join(", ")}`));
|
|
2609
2887
|
await run(chosen);
|
|
2610
2888
|
running = false;
|
|
2611
2889
|
};
|
|
2612
2890
|
const watcher = watch(root, { recursive: true }, (_event, name) => {
|
|
2613
2891
|
if (!name) return;
|
|
2614
|
-
const path =
|
|
2892
|
+
const path = join4(root, name.toString());
|
|
2615
2893
|
if (ignored.some((part) => `${sep2}${relative3(root, path)}${sep2}`.includes(part))) return;
|
|
2616
2894
|
pending.add(path);
|
|
2617
2895
|
if (timer) clearTimeout(timer);
|
|
@@ -2624,104 +2902,16 @@ async function cmdDev(args, flags) {
|
|
|
2624
2902
|
});
|
|
2625
2903
|
});
|
|
2626
2904
|
}
|
|
2627
|
-
async function cmdPush(flags) {
|
|
2628
|
-
const dir = process.cwd();
|
|
2629
|
-
const { token, apiUrl } = await tokenFor(flags);
|
|
2630
|
-
const metaPath = join3(dir, "plugin.json");
|
|
2631
|
-
const pkgPath = join3(dir, "package.json");
|
|
2632
|
-
const metaFile = existsSync3(metaPath) ? metaPath : existsSync3(pkgPath) ? pkgPath : null;
|
|
2633
|
-
if (!metaFile) {
|
|
2634
|
-
fail("No plugin.json here. Run `visualautomate init <name>` to start a plugin.");
|
|
2635
|
-
}
|
|
2636
|
-
const meta = JSON.parse(await readFile3(metaFile, "utf8"));
|
|
2637
|
-
if (!meta.name) fail(`${metaFile} has no "name".`);
|
|
2638
|
-
if (!meta.version || !/^\d+\.\d+\.\d+$/.test(meta.version)) {
|
|
2639
|
-
fail(`${metaFile} needs a "version" like 1.0.0.`);
|
|
2640
|
-
}
|
|
2641
|
-
const manifestPath = resolve3(process.cwd(), "manifest.json");
|
|
2642
|
-
let parsed = {};
|
|
2643
|
-
let manifest = null;
|
|
2644
|
-
if (existsSync3(manifestPath)) {
|
|
2645
|
-
try {
|
|
2646
|
-
const result = parseJsonc(await readFile3(manifestPath, "utf8"));
|
|
2647
|
-
parsed = result.value;
|
|
2648
|
-
manifest = result.json;
|
|
2649
|
-
} catch (error) {
|
|
2650
|
-
fail(`manifest.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
2651
|
-
}
|
|
2652
|
-
}
|
|
2653
|
-
const entry = ["plugin.ts", "plugin.js", "index.ts", "index.js"].map((f) => join3(dir, f)).find((f) => existsSync3(f));
|
|
2654
|
-
if (!entry) {
|
|
2655
|
-
fail("No plugin.ts or plugin.js here. Run `visualautomate init <name>` to start one.");
|
|
2656
|
-
}
|
|
2657
|
-
let code;
|
|
2658
|
-
try {
|
|
2659
|
-
code = await bundlePlugin(entry);
|
|
2660
|
-
} catch (error) {
|
|
2661
|
-
fail(error instanceof Error ? error.message : String(error));
|
|
2662
|
-
}
|
|
2663
|
-
let schemaJson = null;
|
|
2664
|
-
if (!manifest) {
|
|
2665
|
-
const source = await readFile3(entry, "utf8");
|
|
2666
|
-
const match = source.match(/@schema\s*\n([\s\S]*?)(?:\*\s*\/|\*\/)/);
|
|
2667
|
-
if (match) {
|
|
2668
|
-
schemaJson = match[1].replace(/^\s*\*\s?/gm, "").trim();
|
|
2669
|
-
try {
|
|
2670
|
-
parsed = JSON.parse(schemaJson);
|
|
2671
|
-
} catch (error) {
|
|
2672
|
-
fail(`The @schema block is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
2673
|
-
}
|
|
2674
|
-
}
|
|
2675
|
-
}
|
|
2676
|
-
if (!manifest && !schemaJson) {
|
|
2677
|
-
fail(
|
|
2678
|
-
"This plugin declares nothing, so it would have no fields, no ports and no trigger \u2014\nthe canvas would show an empty box. Add a manifest.json next to your code (run\n`visualautomate init` somewhere to see one), or keep an @schema block in the source."
|
|
2679
|
-
);
|
|
2680
|
-
}
|
|
2681
|
-
ok(`Pushing ${meta.name}@${meta.version} to ${apiUrl}...`);
|
|
2682
|
-
const res = await fetch(`${apiUrl}/api/plugins/push`, {
|
|
2683
|
-
method: "POST",
|
|
2684
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
2685
|
-
body: JSON.stringify({
|
|
2686
|
-
name: meta.name,
|
|
2687
|
-
version: meta.version,
|
|
2688
|
-
description: meta.description ?? parsed.description ?? "",
|
|
2689
|
-
category: meta.category ?? "utility",
|
|
2690
|
-
icon: meta.icon ?? parsed.icon ?? "plugin",
|
|
2691
|
-
// Whichever the author actually has. The route takes either.
|
|
2692
|
-
...manifest ? { manifest } : { schemaJson },
|
|
2693
|
-
code
|
|
2694
|
-
})
|
|
2695
|
-
});
|
|
2696
|
-
const text = await res.text();
|
|
2697
|
-
let data = {};
|
|
2698
|
-
try {
|
|
2699
|
-
data = JSON.parse(text);
|
|
2700
|
-
} catch {
|
|
2701
|
-
fail(`The server answered ${res.status} with something that is not JSON:
|
|
2702
|
-
${text.slice(0, 500)}`);
|
|
2703
|
-
}
|
|
2704
|
-
if (res.status === 401) {
|
|
2705
|
-
fail("Your session has expired. Run `visualautomate login` again.");
|
|
2706
|
-
}
|
|
2707
|
-
if (!res.ok || !data.success) {
|
|
2708
|
-
fail(`Push refused: ${data.error ?? `status ${res.status}`}`);
|
|
2709
|
-
}
|
|
2710
|
-
ok(`
|
|
2711
|
-
\u2713 Published (id: ${data.pluginId})`);
|
|
2712
|
-
ok(` A version snapshot was saved, so you can roll back from the dashboard.`);
|
|
2713
|
-
}
|
|
2714
2905
|
function printHelp() {
|
|
2715
2906
|
console.log(`
|
|
2716
|
-
vsa \u2014 build and
|
|
2907
|
+
vsa \u2014 build and test VisualAutomate plugins
|
|
2717
2908
|
|
|
2718
|
-
vsa login sign this machine in
|
|
2719
2909
|
vsa init <name> [--template] start a plugin
|
|
2720
2910
|
vsa dev [<module>] the sandbox, and a run again on every save
|
|
2721
2911
|
vsa test [<module>] run it once, as the platform's check does
|
|
2722
|
-
vsa
|
|
2723
|
-
vsa
|
|
2724
|
-
|
|
2912
|
+
vsa types write the types your editor reads
|
|
2913
|
+
vsa add:property <type> <name> [description] [default]
|
|
2914
|
+
add a field to a module's manifest
|
|
2725
2915
|
vsa --version this CLI's version
|
|
2726
2916
|
|
|
2727
2917
|
\`va\` and \`visualautomate\` are the same command.
|
|
@@ -2733,6 +2923,15 @@ GitHub repository, and at the root of an app's repository, where every folder
|
|
|
2733
2923
|
in modules/ is run (or the one you name: vsa dev send-message). The run's settings come
|
|
2734
2924
|
from visualautomate.test.json, test.json, or --input <file>.
|
|
2735
2925
|
|
|
2926
|
+
Options for \`add:property\`:
|
|
2927
|
+
--module <name> which module, from the root of an app's repository
|
|
2928
|
+
--required the workflow will not run without it
|
|
2929
|
+
--label <text> instead of one made from the name
|
|
2930
|
+
--options <list> for select and multiselect: "Fast:fast,Thorough:thorough"
|
|
2931
|
+
--provider <name> for connection: whose account this is
|
|
2932
|
+
|
|
2933
|
+
Property types: ${PROPERTY_TYPES.join(", ")}
|
|
2934
|
+
|
|
2736
2935
|
Options for \`dev\` and \`test\`:
|
|
2737
2936
|
--module <name> the same as naming it: vsa dev send-message
|
|
2738
2937
|
--tier <name> the sandbox's size: standard, boosted, high or max
|
|
@@ -2741,9 +2940,9 @@ Options for \`dev\` and \`test\`:
|
|
|
2741
2940
|
--local \`dev\` without the sandbox, on this machine's Node
|
|
2742
2941
|
--docker \`test\` inside the sandbox, as \`dev\` does
|
|
2743
2942
|
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2943
|
+
Your plugin reaches the platform through its GitHub repository: push a commit,
|
|
2944
|
+
the tests run there, and Publish on the portal takes it from the commit that
|
|
2945
|
+
passed. Nothing here needs an account.
|
|
2747
2946
|
`);
|
|
2748
2947
|
}
|
|
2749
2948
|
function parse(argv) {
|
|
@@ -2769,28 +2968,22 @@ async function main() {
|
|
|
2769
2968
|
return;
|
|
2770
2969
|
}
|
|
2771
2970
|
switch (command) {
|
|
2772
|
-
case "login":
|
|
2773
|
-
return cmdLogin(flags);
|
|
2774
|
-
case "logout":
|
|
2775
|
-
return cmdLogout();
|
|
2776
|
-
case "whoami":
|
|
2777
|
-
return cmdWhoami(flags);
|
|
2778
2971
|
case "init":
|
|
2779
2972
|
case "new":
|
|
2780
2973
|
return cmdInit(args, flags);
|
|
2781
2974
|
case "test":
|
|
2782
2975
|
return cmdTest(args, flags);
|
|
2976
|
+
case "types":
|
|
2977
|
+
return cmdTypes();
|
|
2978
|
+
case "add:property":
|
|
2979
|
+
case "add:prop":
|
|
2980
|
+
return cmdAddProperty(args, flags);
|
|
2783
2981
|
case "dev":
|
|
2784
2982
|
case "watch":
|
|
2785
2983
|
return cmdDev(args, flags);
|
|
2786
|
-
|
|
2787
|
-
case "publish":
|
|
2788
|
-
return cmdPush(flags);
|
|
2789
|
-
// `plugin test` and `plugin push` are accepted as aliases.
|
|
2984
|
+
// `plugin test` is accepted as an alias.
|
|
2790
2985
|
case "plugin": {
|
|
2791
|
-
|
|
2792
|
-
if (sub === "test") return cmdTest(args, flags);
|
|
2793
|
-
if (sub === "push") return cmdPush(flags);
|
|
2986
|
+
if (args.shift() === "test") return cmdTest(args, flags);
|
|
2794
2987
|
printHelp();
|
|
2795
2988
|
return;
|
|
2796
2989
|
}
|
package/package.json
CHANGED