@visualautomate/cli 0.5.2 → 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 +57 -16
- package/dist/index.js +417 -268
- 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:
|
|
@@ -178,22 +226,15 @@ machine, and each run starts a fresh container.
|
|
|
178
226
|
|
|
179
227
|
`context.media` is not available locally, in or out of Docker.
|
|
180
228
|
|
|
181
|
-
##
|
|
229
|
+
## Publishing
|
|
182
230
|
|
|
183
|
-
|
|
184
|
-
and
|
|
185
|
-
|
|
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.
|
|
186
235
|
|
|
187
236
|
Comments in `manifest.json` are a convenience of this tool: the file on your
|
|
188
|
-
disk may have them,
|
|
189
|
-
|
|
190
|
-
## CI
|
|
191
|
-
|
|
192
|
-
There is no browser on a build machine, so `login` is not available there. Set
|
|
193
|
-
`VISUALAUTOMATE_API_TOKEN` instead — the same token `login` would have stored,
|
|
194
|
-
which you can read out of `~/.visualautomate/config.json`.
|
|
195
|
-
|
|
196
|
-
`VISUALAUTOMATE_API_URL` points the CLI at a different installation.
|
|
237
|
+
disk may have them, and they are stripped before the manifest is read.
|
|
197
238
|
|
|
198
239
|
## Where things are
|
|
199
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,14 +1885,217 @@ 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
2101
|
const wanted2 = name.trim().toLowerCase();
|
|
@@ -1832,9 +2107,9 @@ function matchFolders(folders, name) {
|
|
|
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,14 +2349,14 @@ 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
|
|
|
@@ -2222,32 +2497,7 @@ function runInDocker(run) {
|
|
|
2222
2497
|
}
|
|
2223
2498
|
|
|
2224
2499
|
// src/index.ts
|
|
2225
|
-
var VERSION = true ? "0.
|
|
2226
|
-
var DEFAULT_API_URL = "https://visualautomate.com";
|
|
2227
|
-
function configPath() {
|
|
2228
|
-
return join3(homedir(), ".visualautomate", "config.json");
|
|
2229
|
-
}
|
|
2230
|
-
async function readConfig() {
|
|
2231
|
-
try {
|
|
2232
|
-
return JSON.parse(await readFile3(configPath(), "utf8"));
|
|
2233
|
-
} catch {
|
|
2234
|
-
return {};
|
|
2235
|
-
}
|
|
2236
|
-
}
|
|
2237
|
-
async function writeConfig(next) {
|
|
2238
|
-
const path = configPath();
|
|
2239
|
-
await mkdir2(dirname2(path), { recursive: true });
|
|
2240
|
-
await writeFile2(path, JSON.stringify(next, null, 2) + "\n", { mode: 384 });
|
|
2241
|
-
}
|
|
2242
|
-
async function tokenFor(flags) {
|
|
2243
|
-
const stored = await readConfig();
|
|
2244
|
-
const apiUrl = (flags["api-url"] || process.env.VISUALAUTOMATE_API_URL || stored.apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
2245
|
-
const token = flags.token || process.env.VISUALAUTOMATE_API_TOKEN || stored.token;
|
|
2246
|
-
if (!token) {
|
|
2247
|
-
fail("Not signed in. Run `visualautomate login` first.");
|
|
2248
|
-
}
|
|
2249
|
-
return { token, apiUrl };
|
|
2250
|
-
}
|
|
2500
|
+
var VERSION = true ? "0.6.0" : "0.0.0-dev";
|
|
2251
2501
|
function fail(message) {
|
|
2252
2502
|
console.error(red(message));
|
|
2253
2503
|
throw new SilentExit();
|
|
@@ -2261,97 +2511,6 @@ var SilentExit = class extends Error {
|
|
|
2261
2511
|
function ok(message) {
|
|
2262
2512
|
console.log(message);
|
|
2263
2513
|
}
|
|
2264
|
-
function openBrowser(url) {
|
|
2265
|
-
const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
2266
|
-
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
2267
|
-
try {
|
|
2268
|
-
spawn(command, args, { stdio: "ignore", detached: true }).unref();
|
|
2269
|
-
} catch {
|
|
2270
|
-
}
|
|
2271
|
-
}
|
|
2272
|
-
async function cmdLogin(flags) {
|
|
2273
|
-
const stored = await readConfig();
|
|
2274
|
-
const apiUrl = (flags["api-url"] || process.env.VISUALAUTOMATE_API_URL || stored.apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
2275
|
-
const state = randomBytes(24).toString("base64url");
|
|
2276
|
-
const token = await new Promise((resolveToken, rejectToken) => {
|
|
2277
|
-
const server = createServer((req, res) => {
|
|
2278
|
-
if (!req.url?.startsWith("/callback") || req.method !== "POST") {
|
|
2279
|
-
res.writeHead(404).end("Not found");
|
|
2280
|
-
return;
|
|
2281
|
-
}
|
|
2282
|
-
let body = "";
|
|
2283
|
-
req.on("data", (chunk) => {
|
|
2284
|
-
body += chunk;
|
|
2285
|
-
if (body.length > 8192) req.destroy();
|
|
2286
|
-
});
|
|
2287
|
-
req.on("end", () => {
|
|
2288
|
-
const fields = new URLSearchParams(body);
|
|
2289
|
-
const answer = (status, text) => {
|
|
2290
|
-
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
2291
|
-
res.end(
|
|
2292
|
-
`<!doctype html><meta charset="utf-8"><title>VisualAutomate</title><body style="font:16px system-ui;padding:3rem;max-width:32rem;margin:auto">${text}</body>`
|
|
2293
|
-
);
|
|
2294
|
-
};
|
|
2295
|
-
if (fields.get("state") !== state) {
|
|
2296
|
-
answer(400, "<h1>That did not match</h1><p>Run <code>visualautomate login</code> again.</p>");
|
|
2297
|
-
return;
|
|
2298
|
-
}
|
|
2299
|
-
const received = fields.get("token");
|
|
2300
|
-
if (!received) {
|
|
2301
|
-
answer(400, "<h1>No token in the reply</h1><p>Run <code>visualautomate login</code> again.</p>");
|
|
2302
|
-
return;
|
|
2303
|
-
}
|
|
2304
|
-
answer(200, "<h1>Signed in</h1><p>You can close this tab and go back to your terminal.</p>");
|
|
2305
|
-
server.close();
|
|
2306
|
-
resolveToken(received);
|
|
2307
|
-
});
|
|
2308
|
-
});
|
|
2309
|
-
server.listen(0, "127.0.0.1", () => {
|
|
2310
|
-
const address = server.address();
|
|
2311
|
-
if (!address || typeof address === "string") {
|
|
2312
|
-
rejectToken(new Error("Could not open a port to listen on"));
|
|
2313
|
-
return;
|
|
2314
|
-
}
|
|
2315
|
-
const url = `${apiUrl}/cli/login?port=${address.port}&state=${state}`;
|
|
2316
|
-
ok(`Opening ${url}
|
|
2317
|
-
`);
|
|
2318
|
-
ok("If your browser did not open, paste that address into it.");
|
|
2319
|
-
openBrowser(url);
|
|
2320
|
-
});
|
|
2321
|
-
server.on("error", rejectToken);
|
|
2322
|
-
setTimeout(() => {
|
|
2323
|
-
server.close();
|
|
2324
|
-
rejectToken(new Error("Timed out after five minutes waiting for the browser."));
|
|
2325
|
-
}, 5 * 6e4).unref();
|
|
2326
|
-
});
|
|
2327
|
-
const who = await whoami(apiUrl, token);
|
|
2328
|
-
await writeConfig({ token, apiUrl, email: who.email });
|
|
2329
|
-
ok(green(`
|
|
2330
|
-
\u2713 Signed in as ${who.email}`));
|
|
2331
|
-
ok(` Token stored in ${configPath()}`);
|
|
2332
|
-
}
|
|
2333
|
-
async function whoami(apiUrl, token) {
|
|
2334
|
-
const res = await fetch(`${apiUrl}/api/cli/whoami`, {
|
|
2335
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
2336
|
-
});
|
|
2337
|
-
if (res.status === 401) fail("That token is not valid. Run `visualautomate login` again.");
|
|
2338
|
-
if (!res.ok) fail(`Could not reach ${apiUrl} (status ${res.status}).`);
|
|
2339
|
-
const data = await res.json();
|
|
2340
|
-
return { email: data.email ?? "(unknown)", developer: Boolean(data.pluginDeveloper) };
|
|
2341
|
-
}
|
|
2342
|
-
async function cmdWhoami(flags) {
|
|
2343
|
-
const { token, apiUrl } = await tokenFor(flags);
|
|
2344
|
-
const who = await whoami(apiUrl, token);
|
|
2345
|
-
ok(`${who.email} on ${apiUrl}`);
|
|
2346
|
-
if (!who.developer) {
|
|
2347
|
-
ok("\nThis account is not a plugin developer yet, so `push` will be refused.");
|
|
2348
|
-
ok("Turn it on from the dashboard under Developer settings.");
|
|
2349
|
-
}
|
|
2350
|
-
}
|
|
2351
|
-
async function cmdLogout() {
|
|
2352
|
-
await writeConfig({});
|
|
2353
|
-
ok("Signed out. The token file is empty.");
|
|
2354
|
-
}
|
|
2355
2514
|
async function cmdInit(args, flags) {
|
|
2356
2515
|
const name = args[0];
|
|
2357
2516
|
if (!name) fail("Usage: visualautomate init <name> [--template <name>]");
|
|
@@ -2361,22 +2520,22 @@ async function cmdInit(args, flags) {
|
|
|
2361
2520
|
fail(`Unknown template "${templateName}". Available: ${Object.keys(TEMPLATES).join(", ")}`);
|
|
2362
2521
|
}
|
|
2363
2522
|
const target = resolve3(process.cwd(), name);
|
|
2364
|
-
if (
|
|
2523
|
+
if (existsSync4(target)) fail(`"${name}" already exists here.`);
|
|
2365
2524
|
const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
2366
2525
|
await mkdir2(target, { recursive: true });
|
|
2367
|
-
await writeFile2(
|
|
2368
|
-
await writeFile2(
|
|
2526
|
+
await writeFile2(join4(target, "plugin.ts"), template.code);
|
|
2527
|
+
await writeFile2(join4(target, "manifest.json"), template.manifest);
|
|
2369
2528
|
await writeFile2(
|
|
2370
|
-
|
|
2529
|
+
join4(target, "plugin.json"),
|
|
2371
2530
|
JSON.stringify(
|
|
2372
2531
|
{ name: slug, version: "1.0.0", description: template.description, category: template.category },
|
|
2373
2532
|
null,
|
|
2374
2533
|
2
|
|
2375
2534
|
) + "\n"
|
|
2376
2535
|
);
|
|
2377
|
-
await writeFile2(
|
|
2536
|
+
await writeFile2(join4(target, "visualautomate.d.ts"), TYPE_DECLARATIONS);
|
|
2378
2537
|
await writeFile2(
|
|
2379
|
-
|
|
2538
|
+
join4(target, "tsconfig.json"),
|
|
2380
2539
|
JSON.stringify(
|
|
2381
2540
|
{
|
|
2382
2541
|
compilerOptions: {
|
|
@@ -2395,11 +2554,11 @@ async function cmdInit(args, flags) {
|
|
|
2395
2554
|
) + "\n"
|
|
2396
2555
|
);
|
|
2397
2556
|
await writeFile2(
|
|
2398
|
-
|
|
2557
|
+
join4(target, "test.json"),
|
|
2399
2558
|
JSON.stringify(template.testInput, null, 2) + "\n"
|
|
2400
2559
|
);
|
|
2401
2560
|
await writeFile2(
|
|
2402
|
-
|
|
2561
|
+
join4(target, "README.md"),
|
|
2403
2562
|
`# ${name}
|
|
2404
2563
|
|
|
2405
2564
|
${template.description}
|
|
@@ -2462,8 +2621,8 @@ ${bold(`\u25B8 ${target.name}`)}`);
|
|
|
2462
2621
|
const exported = loadPluginCode(code, sandboxRequire(packageLoader(target.dir)));
|
|
2463
2622
|
const plugin = typeof exported === "function" ? exported : typeof exported?.execute === "function" ? exported : exported?.default;
|
|
2464
2623
|
let outputs = [];
|
|
2465
|
-
const manifestPath =
|
|
2466
|
-
if (
|
|
2624
|
+
const manifestPath = join4(target.dir, "manifest.json");
|
|
2625
|
+
if (existsSync4(manifestPath)) {
|
|
2467
2626
|
try {
|
|
2468
2627
|
const manifest = parseJsonc(await readFile3(manifestPath, "utf8")).value;
|
|
2469
2628
|
outputs = manifest.outputs ?? [];
|
|
@@ -2512,7 +2671,7 @@ ${bold(`\u25B8 ${target.name}`)}`);
|
|
|
2512
2671
|
}
|
|
2513
2672
|
if (persist) {
|
|
2514
2673
|
const refused = await savePersisted(target.dir, result.files, result.state);
|
|
2515
|
-
say(`${dim("kept")} ${dim(`storage and state in ${relative3(process.cwd(),
|
|
2674
|
+
say(`${dim("kept")} ${dim(`storage and state in ${relative3(process.cwd(), join4(target.dir, ".visualautomate")) || ".visualautomate"}`)}`);
|
|
2516
2675
|
for (const path of refused) say(yellow(`! storage path "${path}" points outside the storage folder and was not written`));
|
|
2517
2676
|
}
|
|
2518
2677
|
if (port && outputs.length > 0 && !outputs.includes(port)) {
|
|
@@ -2541,9 +2700,9 @@ ${passed === targets.length ? green(bold(tally)) : red(bold(tally))}`);
|
|
|
2541
2700
|
return passed === targets.length;
|
|
2542
2701
|
}
|
|
2543
2702
|
function packageLoader(dir) {
|
|
2544
|
-
const fromProject = createRequire(
|
|
2703
|
+
const fromProject = createRequire(join4(dir, "noop.js"));
|
|
2545
2704
|
const imageDir = process.env.VISUALAUTOMATE_PACKAGES;
|
|
2546
|
-
const fromImage = imageDir ? createRequire(
|
|
2705
|
+
const fromImage = imageDir ? createRequire(join4(imageDir, "noop.js")) : null;
|
|
2547
2706
|
return (name) => {
|
|
2548
2707
|
try {
|
|
2549
2708
|
return fromProject(name);
|
|
@@ -2605,16 +2764,90 @@ function sayWhatRuns(targets, picked) {
|
|
|
2605
2764
|
ok(`Running ${targets.length} modules: ${targets.map((t) => t.name.replace(/^modules\//, "")).join(", ")}`);
|
|
2606
2765
|
if (!picked) ok(dim(`One of them: vsa dev ${targets[0].name.replace(/^modules\//, "")}`));
|
|
2607
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} */"));
|
|
2784
|
+
}
|
|
2608
2785
|
async function cmdTest(args, flags) {
|
|
2609
2786
|
const targets = targetsOrFail(args, flags);
|
|
2787
|
+
keepTypes();
|
|
2610
2788
|
if (flags.docker) prepareSandbox(flags);
|
|
2611
2789
|
const passed = flags.docker ? runTargetsInDocker(targets, targets, flags) : await runTargets(targets, flags);
|
|
2612
2790
|
if (!passed) process.exitCode = 1;
|
|
2613
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
|
+
}
|
|
2614
2846
|
async function cmdDev(args, flags) {
|
|
2615
2847
|
const root = process.cwd();
|
|
2616
2848
|
const picked = args[0] ?? flags.module;
|
|
2617
2849
|
let targets = targetsOrFail(args, flags);
|
|
2850
|
+
const typesWritten = keepTypes();
|
|
2618
2851
|
const inSandbox = flags.local !== "true";
|
|
2619
2852
|
if (inSandbox) prepareSandbox(flags);
|
|
2620
2853
|
const header = () => {
|
|
@@ -2625,6 +2858,7 @@ async function cmdDev(args, flags) {
|
|
|
2625
2858
|
header();
|
|
2626
2859
|
ok(dim(inSandbox ? `in ${imageFor(flags)}, tier ${flags.tier || "standard"}` : "on this machine, not in the sandbox"));
|
|
2627
2860
|
sayWhatRuns(targets, Boolean(picked));
|
|
2861
|
+
if (typesWritten.length > 0) ok(dim(`Wrote ${typesWritten.join(" and ")} \u2014 your editor now knows what context is`));
|
|
2628
2862
|
await run(targets);
|
|
2629
2863
|
const ignored = [`${sep2}node_modules${sep2}`, `${sep2}.git${sep2}`, `${sep2}dist${sep2}`, `${sep2}.visualautomate${sep2}`];
|
|
2630
2864
|
const pending = /* @__PURE__ */ new Set();
|
|
@@ -2655,7 +2889,7 @@ async function cmdDev(args, flags) {
|
|
|
2655
2889
|
};
|
|
2656
2890
|
const watcher = watch(root, { recursive: true }, (_event, name) => {
|
|
2657
2891
|
if (!name) return;
|
|
2658
|
-
const path =
|
|
2892
|
+
const path = join4(root, name.toString());
|
|
2659
2893
|
if (ignored.some((part) => `${sep2}${relative3(root, path)}${sep2}`.includes(part))) return;
|
|
2660
2894
|
pending.add(path);
|
|
2661
2895
|
if (timer) clearTimeout(timer);
|
|
@@ -2668,104 +2902,16 @@ async function cmdDev(args, flags) {
|
|
|
2668
2902
|
});
|
|
2669
2903
|
});
|
|
2670
2904
|
}
|
|
2671
|
-
async function cmdPush(flags) {
|
|
2672
|
-
const dir = process.cwd();
|
|
2673
|
-
const { token, apiUrl } = await tokenFor(flags);
|
|
2674
|
-
const metaPath = join3(dir, "plugin.json");
|
|
2675
|
-
const pkgPath = join3(dir, "package.json");
|
|
2676
|
-
const metaFile = existsSync3(metaPath) ? metaPath : existsSync3(pkgPath) ? pkgPath : null;
|
|
2677
|
-
if (!metaFile) {
|
|
2678
|
-
fail("No plugin.json here. Run `visualautomate init <name>` to start a plugin.");
|
|
2679
|
-
}
|
|
2680
|
-
const meta = JSON.parse(await readFile3(metaFile, "utf8"));
|
|
2681
|
-
if (!meta.name) fail(`${metaFile} has no "name".`);
|
|
2682
|
-
if (!meta.version || !/^\d+\.\d+\.\d+$/.test(meta.version)) {
|
|
2683
|
-
fail(`${metaFile} needs a "version" like 1.0.0.`);
|
|
2684
|
-
}
|
|
2685
|
-
const manifestPath = resolve3(process.cwd(), "manifest.json");
|
|
2686
|
-
let parsed = {};
|
|
2687
|
-
let manifest = null;
|
|
2688
|
-
if (existsSync3(manifestPath)) {
|
|
2689
|
-
try {
|
|
2690
|
-
const result = parseJsonc(await readFile3(manifestPath, "utf8"));
|
|
2691
|
-
parsed = result.value;
|
|
2692
|
-
manifest = result.json;
|
|
2693
|
-
} catch (error) {
|
|
2694
|
-
fail(`manifest.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
2695
|
-
}
|
|
2696
|
-
}
|
|
2697
|
-
const entry = ["plugin.ts", "plugin.js", "index.ts", "index.js"].map((f) => join3(dir, f)).find((f) => existsSync3(f));
|
|
2698
|
-
if (!entry) {
|
|
2699
|
-
fail("No plugin.ts or plugin.js here. Run `visualautomate init <name>` to start one.");
|
|
2700
|
-
}
|
|
2701
|
-
let code;
|
|
2702
|
-
try {
|
|
2703
|
-
code = await bundlePlugin(entry);
|
|
2704
|
-
} catch (error) {
|
|
2705
|
-
fail(error instanceof Error ? error.message : String(error));
|
|
2706
|
-
}
|
|
2707
|
-
let schemaJson = null;
|
|
2708
|
-
if (!manifest) {
|
|
2709
|
-
const source = await readFile3(entry, "utf8");
|
|
2710
|
-
const match = source.match(/@schema\s*\n([\s\S]*?)(?:\*\s*\/|\*\/)/);
|
|
2711
|
-
if (match) {
|
|
2712
|
-
schemaJson = match[1].replace(/^\s*\*\s?/gm, "").trim();
|
|
2713
|
-
try {
|
|
2714
|
-
parsed = JSON.parse(schemaJson);
|
|
2715
|
-
} catch (error) {
|
|
2716
|
-
fail(`The @schema block is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
2717
|
-
}
|
|
2718
|
-
}
|
|
2719
|
-
}
|
|
2720
|
-
if (!manifest && !schemaJson) {
|
|
2721
|
-
fail(
|
|
2722
|
-
"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."
|
|
2723
|
-
);
|
|
2724
|
-
}
|
|
2725
|
-
ok(`Pushing ${meta.name}@${meta.version} to ${apiUrl}...`);
|
|
2726
|
-
const res = await fetch(`${apiUrl}/api/plugins/push`, {
|
|
2727
|
-
method: "POST",
|
|
2728
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
2729
|
-
body: JSON.stringify({
|
|
2730
|
-
name: meta.name,
|
|
2731
|
-
version: meta.version,
|
|
2732
|
-
description: meta.description ?? parsed.description ?? "",
|
|
2733
|
-
category: meta.category ?? "utility",
|
|
2734
|
-
icon: meta.icon ?? parsed.icon ?? "plugin",
|
|
2735
|
-
// Whichever the author actually has. The route takes either.
|
|
2736
|
-
...manifest ? { manifest } : { schemaJson },
|
|
2737
|
-
code
|
|
2738
|
-
})
|
|
2739
|
-
});
|
|
2740
|
-
const text = await res.text();
|
|
2741
|
-
let data = {};
|
|
2742
|
-
try {
|
|
2743
|
-
data = JSON.parse(text);
|
|
2744
|
-
} catch {
|
|
2745
|
-
fail(`The server answered ${res.status} with something that is not JSON:
|
|
2746
|
-
${text.slice(0, 500)}`);
|
|
2747
|
-
}
|
|
2748
|
-
if (res.status === 401) {
|
|
2749
|
-
fail("Your session has expired. Run `visualautomate login` again.");
|
|
2750
|
-
}
|
|
2751
|
-
if (!res.ok || !data.success) {
|
|
2752
|
-
fail(`Push refused: ${data.error ?? `status ${res.status}`}`);
|
|
2753
|
-
}
|
|
2754
|
-
ok(green(`
|
|
2755
|
-
\u2713 Published (id: ${data.pluginId})`));
|
|
2756
|
-
ok(` A version snapshot was saved, so you can roll back from the dashboard.`);
|
|
2757
|
-
}
|
|
2758
2905
|
function printHelp() {
|
|
2759
2906
|
console.log(`
|
|
2760
|
-
vsa \u2014 build and
|
|
2907
|
+
vsa \u2014 build and test VisualAutomate plugins
|
|
2761
2908
|
|
|
2762
|
-
vsa login sign this machine in
|
|
2763
2909
|
vsa init <name> [--template] start a plugin
|
|
2764
2910
|
vsa dev [<module>] the sandbox, and a run again on every save
|
|
2765
2911
|
vsa test [<module>] run it once, as the platform's check does
|
|
2766
|
-
vsa
|
|
2767
|
-
vsa
|
|
2768
|
-
|
|
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
|
|
2769
2915
|
vsa --version this CLI's version
|
|
2770
2916
|
|
|
2771
2917
|
\`va\` and \`visualautomate\` are the same command.
|
|
@@ -2777,6 +2923,15 @@ GitHub repository, and at the root of an app's repository, where every folder
|
|
|
2777
2923
|
in modules/ is run (or the one you name: vsa dev send-message). The run's settings come
|
|
2778
2924
|
from visualautomate.test.json, test.json, or --input <file>.
|
|
2779
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
|
+
|
|
2780
2935
|
Options for \`dev\` and \`test\`:
|
|
2781
2936
|
--module <name> the same as naming it: vsa dev send-message
|
|
2782
2937
|
--tier <name> the sandbox's size: standard, boosted, high or max
|
|
@@ -2785,9 +2940,9 @@ Options for \`dev\` and \`test\`:
|
|
|
2785
2940
|
--local \`dev\` without the sandbox, on this machine's Node
|
|
2786
2941
|
--docker \`test\` inside the sandbox, as \`dev\` does
|
|
2787
2942
|
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
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.
|
|
2791
2946
|
`);
|
|
2792
2947
|
}
|
|
2793
2948
|
function parse(argv) {
|
|
@@ -2813,28 +2968,22 @@ async function main() {
|
|
|
2813
2968
|
return;
|
|
2814
2969
|
}
|
|
2815
2970
|
switch (command) {
|
|
2816
|
-
case "login":
|
|
2817
|
-
return cmdLogin(flags);
|
|
2818
|
-
case "logout":
|
|
2819
|
-
return cmdLogout();
|
|
2820
|
-
case "whoami":
|
|
2821
|
-
return cmdWhoami(flags);
|
|
2822
2971
|
case "init":
|
|
2823
2972
|
case "new":
|
|
2824
2973
|
return cmdInit(args, flags);
|
|
2825
2974
|
case "test":
|
|
2826
2975
|
return cmdTest(args, flags);
|
|
2976
|
+
case "types":
|
|
2977
|
+
return cmdTypes();
|
|
2978
|
+
case "add:property":
|
|
2979
|
+
case "add:prop":
|
|
2980
|
+
return cmdAddProperty(args, flags);
|
|
2827
2981
|
case "dev":
|
|
2828
2982
|
case "watch":
|
|
2829
2983
|
return cmdDev(args, flags);
|
|
2830
|
-
|
|
2831
|
-
case "publish":
|
|
2832
|
-
return cmdPush(flags);
|
|
2833
|
-
// `plugin test` and `plugin push` are accepted as aliases.
|
|
2984
|
+
// `plugin test` is accepted as an alias.
|
|
2834
2985
|
case "plugin": {
|
|
2835
|
-
|
|
2836
|
-
if (sub === "test") return cmdTest(args, flags);
|
|
2837
|
-
if (sub === "push") return cmdPush(flags);
|
|
2986
|
+
if (args.shift() === "test") return cmdTest(args, flags);
|
|
2838
2987
|
printHelp();
|
|
2839
2988
|
return;
|
|
2840
2989
|
}
|
package/package.json
CHANGED