@visualautomate/cli 0.5.2 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -16
- package/dist/index.js +453 -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,56 @@ 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
|
+
The same field is added to `visualautomate.test.json` under `config`, with its
|
|
81
|
+
default or a value of the right shape, so the next `vsa test` passes something
|
|
82
|
+
for it instead of nothing. A value you have already put there is kept.
|
|
83
|
+
|
|
84
|
+
At the root of an app's repository, `--module <name>` says which one — the same
|
|
85
|
+
short names `test` and `dev` take. The manifest is edited where it stands: your
|
|
86
|
+
comments, the examples underneath and your own formatting stay as they are.
|
|
87
|
+
|
|
36
88
|
## `test`
|
|
37
89
|
|
|
38
90
|
Runs your plugin the way the platform would, in three steps:
|
|
@@ -178,22 +230,15 @@ machine, and each run starts a fresh container.
|
|
|
178
230
|
|
|
179
231
|
`context.media` is not available locally, in or out of Docker.
|
|
180
232
|
|
|
181
|
-
##
|
|
233
|
+
## Publishing
|
|
182
234
|
|
|
183
|
-
|
|
184
|
-
and
|
|
185
|
-
|
|
235
|
+
Not from here. Connect the repository to your app on the portal, push your
|
|
236
|
+
commit, and the platform runs the same checks `vsa test` runs against every
|
|
237
|
+
module it touched. Publish on the portal takes the code from a commit that
|
|
238
|
+
passed, and a version snapshot is saved so you can roll back.
|
|
186
239
|
|
|
187
240
|
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.
|
|
241
|
+
disk may have them, and they are stripped before the manifest is read.
|
|
197
242
|
|
|
198
243
|
## Where things are
|
|
199
244
|
|
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 { basename as basename3, 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,239 @@ 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 withMember(text, container, key, value) {
|
|
2055
|
+
const bare = stripJsonComments(text);
|
|
2056
|
+
const { object, members } = topLevelMembers(bare);
|
|
2057
|
+
if (!object || object.end === -1) throw new Error("This file is not a JSON object.");
|
|
2058
|
+
const indent = indentOf(text);
|
|
2059
|
+
const entry = `"${key}": ${indented(value, indent, 2)}`;
|
|
2060
|
+
const existing = members.get(container);
|
|
2061
|
+
let next;
|
|
2062
|
+
if (existing && bare[existing.start] === "{") {
|
|
2063
|
+
const last = lastContent(bare, existing.start, existing.end);
|
|
2064
|
+
const oneLine = last !== -1 && !text.slice(existing.start, existing.end).includes("\n");
|
|
2065
|
+
next = last === -1 ? text.slice(0, existing.start + 1) + `
|
|
2066
|
+
${indent.repeat(2)}${entry}
|
|
2067
|
+
${indent}` + text.slice(existing.end) : oneLine ? text.slice(0, last + 1) + `, "${key}": ${JSON.stringify(value)}` + text.slice(last + 1) : text.slice(0, last + 1) + `,
|
|
2068
|
+
${indent.repeat(2)}${entry}` + text.slice(last + 1);
|
|
2069
|
+
} else if (existing) {
|
|
2070
|
+
throw new Error(`"${container}" in this file is not an object, so nothing can be added to it.`);
|
|
2071
|
+
} else {
|
|
2072
|
+
const block = `"${container}": {
|
|
2073
|
+
${indent.repeat(2)}${entry}
|
|
2074
|
+
${indent}}`;
|
|
2075
|
+
const last = lastContent(bare, object.start, object.end);
|
|
2076
|
+
next = last === -1 ? text.slice(0, object.start + 1) + `
|
|
2077
|
+
${indent}${block}
|
|
2078
|
+
` + text.slice(object.end) : text.slice(0, last + 1) + `,
|
|
2079
|
+
${indent}${block}` + text.slice(last + 1);
|
|
2080
|
+
}
|
|
2081
|
+
const parsed = JSON.parse(stripJsonComments(next));
|
|
2082
|
+
if (!parsed[container] || !(key in parsed[container])) {
|
|
2083
|
+
throw new Error(`${key} could not be placed in this file. Add it by hand, or say where this went wrong.`);
|
|
2084
|
+
}
|
|
2085
|
+
return next;
|
|
2086
|
+
}
|
|
2087
|
+
function hasMember(text, container, key) {
|
|
2088
|
+
const parsed = JSON.parse(stripJsonComments(text));
|
|
2089
|
+
const inside = parsed[container];
|
|
2090
|
+
return Boolean(inside && typeof inside === "object" && !Array.isArray(inside) && key in inside);
|
|
2091
|
+
}
|
|
2092
|
+
function sampleFor(property) {
|
|
2093
|
+
if ("default" in property) return property.default;
|
|
2094
|
+
switch (property.type) {
|
|
2095
|
+
case "number":
|
|
2096
|
+
return 0;
|
|
2097
|
+
case "boolean":
|
|
2098
|
+
return false;
|
|
2099
|
+
case "multiselect":
|
|
2100
|
+
case "paths":
|
|
2101
|
+
return [];
|
|
2102
|
+
case "json":
|
|
2103
|
+
return {};
|
|
2104
|
+
case "select": {
|
|
2105
|
+
const options = property.options;
|
|
2106
|
+
return options?.[0]?.value ?? "";
|
|
2107
|
+
}
|
|
2108
|
+
default:
|
|
2109
|
+
return "";
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
1816
2113
|
// src/project.ts
|
|
1817
|
-
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2114
|
+
import { existsSync as existsSync2, readdirSync, statSync } from "node:fs";
|
|
1818
2115
|
import { readFile } from "node:fs/promises";
|
|
1819
|
-
import { basename, join, relative, resolve } from "node:path";
|
|
2116
|
+
import { basename as basename2, join as join2, relative, resolve } from "node:path";
|
|
1820
2117
|
var TEST_FILES = ["visualautomate.test.json", "test.json"];
|
|
1821
2118
|
var ENTRIES = ["plugin.ts", "plugin.js", "index.ts", "index.js"];
|
|
1822
2119
|
function entryIn(dir) {
|
|
1823
|
-
return ENTRIES.map((file2) =>
|
|
2120
|
+
return ENTRIES.map((file2) => join2(dir, file2)).find((file2) => existsSync2(file2)) ?? null;
|
|
1824
2121
|
}
|
|
1825
2122
|
function matchFolders(folders, name) {
|
|
1826
2123
|
const wanted2 = name.trim().toLowerCase();
|
|
@@ -1832,9 +2129,9 @@ function matchFolders(folders, name) {
|
|
|
1832
2129
|
}
|
|
1833
2130
|
function findTargets(cwd, only) {
|
|
1834
2131
|
const dir = resolve(cwd);
|
|
1835
|
-
const modulesDir =
|
|
1836
|
-
if (!entryIn(dir) &&
|
|
1837
|
-
const folders = readdirSync(modulesDir, { withFileTypes: true }).filter((d) => d.isDirectory() &&
|
|
2132
|
+
const modulesDir = join2(dir, "modules");
|
|
2133
|
+
if (!entryIn(dir) && existsSync2(modulesDir) && statSync(modulesDir).isDirectory()) {
|
|
2134
|
+
const folders = readdirSync(modulesDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync2(join2(modulesDir, d.name, "manifest.json"))).map((d) => d.name).sort();
|
|
1838
2135
|
if (folders.length === 0) {
|
|
1839
2136
|
throw new Error("There is a modules/ folder here, but none of its folders has a manifest.json.");
|
|
1840
2137
|
}
|
|
@@ -1842,9 +2139,9 @@ function findTargets(cwd, only) {
|
|
|
1842
2139
|
if (only && chosen.length === 0) {
|
|
1843
2140
|
throw new Error(`No module matches "${only}". This repository has: ${folders.join(", ")}.`);
|
|
1844
2141
|
}
|
|
1845
|
-
return chosen.map((folder) => targetIn(
|
|
2142
|
+
return chosen.map((folder) => targetIn(join2(modulesDir, folder), `modules/${folder}`)).filter(Boolean);
|
|
1846
2143
|
}
|
|
1847
|
-
const target = targetIn(dir,
|
|
2144
|
+
const target = targetIn(dir, basename2(dir));
|
|
1848
2145
|
if (!target) {
|
|
1849
2146
|
throw new Error(
|
|
1850
2147
|
"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 +2152,7 @@ function findTargets(cwd, only) {
|
|
|
1855
2152
|
function targetIn(dir, name) {
|
|
1856
2153
|
const entry = entryIn(dir);
|
|
1857
2154
|
if (!entry) return null;
|
|
1858
|
-
const raw =
|
|
2155
|
+
const raw = basename2(entry) === "index.js" && !existsSync2(join2(dir, "plugin.json"));
|
|
1859
2156
|
return { name, dir, entry, raw };
|
|
1860
2157
|
}
|
|
1861
2158
|
function targetForPath(targets, changed) {
|
|
@@ -1869,9 +2166,9 @@ function isPlainObject(value) {
|
|
|
1869
2166
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1870
2167
|
}
|
|
1871
2168
|
async function readTestFile(dir, override) {
|
|
1872
|
-
const path = override ? resolve(override) : TEST_FILES.map((f) =>
|
|
2169
|
+
const path = override ? resolve(override) : TEST_FILES.map((f) => join2(dir, f)).find((f) => existsSync2(f)) ?? null;
|
|
1873
2170
|
if (!path) return { path: null, config: {}, input: {} };
|
|
1874
|
-
if (!
|
|
2171
|
+
if (!existsSync2(path)) throw new Error(`${path} does not exist.`);
|
|
1875
2172
|
let raw;
|
|
1876
2173
|
try {
|
|
1877
2174
|
raw = JSON.parse(await readFile(path, "utf8"));
|
|
@@ -2023,12 +2320,12 @@ function guardFetch(policy, base, onDenied) {
|
|
|
2023
2320
|
}
|
|
2024
2321
|
|
|
2025
2322
|
// src/persist.ts
|
|
2026
|
-
import { existsSync as
|
|
2323
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
2027
2324
|
import { mkdir, readdir, readFile as readFile2, rm, writeFile } from "node:fs/promises";
|
|
2028
|
-
import { dirname, join as
|
|
2325
|
+
import { dirname as dirname2, join as join3, relative as relative2, resolve as resolve2, sep } from "node:path";
|
|
2029
2326
|
var PERSIST_DIR = ".visualautomate";
|
|
2030
2327
|
function persistEnabled(dir, flag) {
|
|
2031
|
-
return flag === "true" ||
|
|
2328
|
+
return flag === "true" || existsSync3(join3(dir, PERSIST_DIR));
|
|
2032
2329
|
}
|
|
2033
2330
|
function diskPathFor(storageRoot, path) {
|
|
2034
2331
|
if (!path || path.includes("\0")) return null;
|
|
@@ -2038,22 +2335,22 @@ function diskPathFor(storageRoot, path) {
|
|
|
2038
2335
|
return target;
|
|
2039
2336
|
}
|
|
2040
2337
|
async function walk(root, dir = root) {
|
|
2041
|
-
if (!
|
|
2338
|
+
if (!existsSync3(dir)) return [];
|
|
2042
2339
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
2043
2340
|
const nested = await Promise.all(
|
|
2044
|
-
entries.map((entry) => entry.isDirectory() ? walk(root,
|
|
2341
|
+
entries.map((entry) => entry.isDirectory() ? walk(root, join3(dir, entry.name)) : Promise.resolve([join3(dir, entry.name)]))
|
|
2045
2342
|
);
|
|
2046
2343
|
return nested.flat();
|
|
2047
2344
|
}
|
|
2048
2345
|
async function loadPersisted(dir) {
|
|
2049
|
-
const storageRoot =
|
|
2346
|
+
const storageRoot = join3(dir, PERSIST_DIR, "storage");
|
|
2050
2347
|
const storage = {};
|
|
2051
2348
|
for (const file2 of await walk(storageRoot)) {
|
|
2052
2349
|
storage[relative2(storageRoot, file2).split(sep).join("/")] = new Uint8Array(await readFile2(file2));
|
|
2053
2350
|
}
|
|
2054
2351
|
let state = {};
|
|
2055
|
-
const statePath =
|
|
2056
|
-
if (
|
|
2352
|
+
const statePath = join3(dir, PERSIST_DIR, "state.json");
|
|
2353
|
+
if (existsSync3(statePath)) {
|
|
2057
2354
|
try {
|
|
2058
2355
|
const parsed = JSON.parse(await readFile2(statePath, "utf8"));
|
|
2059
2356
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) state = parsed;
|
|
@@ -2064,7 +2361,7 @@ async function loadPersisted(dir) {
|
|
|
2064
2361
|
return { storage, state };
|
|
2065
2362
|
}
|
|
2066
2363
|
async function savePersisted(dir, files, state) {
|
|
2067
|
-
const storageRoot =
|
|
2364
|
+
const storageRoot = join3(dir, PERSIST_DIR, "storage");
|
|
2068
2365
|
const refused = [];
|
|
2069
2366
|
const keep = /* @__PURE__ */ new Set();
|
|
2070
2367
|
for (const [path, file2] of files) {
|
|
@@ -2074,14 +2371,14 @@ async function savePersisted(dir, files, state) {
|
|
|
2074
2371
|
continue;
|
|
2075
2372
|
}
|
|
2076
2373
|
keep.add(target);
|
|
2077
|
-
await mkdir(
|
|
2374
|
+
await mkdir(dirname2(target), { recursive: true });
|
|
2078
2375
|
await writeFile(target, file2.bytes);
|
|
2079
2376
|
}
|
|
2080
2377
|
for (const existing of await walk(storageRoot)) {
|
|
2081
2378
|
if (!keep.has(existing)) await rm(existing);
|
|
2082
2379
|
}
|
|
2083
|
-
await mkdir(
|
|
2084
|
-
await writeFile(
|
|
2380
|
+
await mkdir(join3(dir, PERSIST_DIR), { recursive: true });
|
|
2381
|
+
await writeFile(join3(dir, PERSIST_DIR, "state.json"), JSON.stringify(Object.fromEntries(state), null, 2) + "\n");
|
|
2085
2382
|
return refused;
|
|
2086
2383
|
}
|
|
2087
2384
|
|
|
@@ -2222,32 +2519,7 @@ function runInDocker(run) {
|
|
|
2222
2519
|
}
|
|
2223
2520
|
|
|
2224
2521
|
// 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
|
-
}
|
|
2522
|
+
var VERSION = true ? "0.6.1" : "0.0.0-dev";
|
|
2251
2523
|
function fail(message) {
|
|
2252
2524
|
console.error(red(message));
|
|
2253
2525
|
throw new SilentExit();
|
|
@@ -2261,97 +2533,6 @@ var SilentExit = class extends Error {
|
|
|
2261
2533
|
function ok(message) {
|
|
2262
2534
|
console.log(message);
|
|
2263
2535
|
}
|
|
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
2536
|
async function cmdInit(args, flags) {
|
|
2356
2537
|
const name = args[0];
|
|
2357
2538
|
if (!name) fail("Usage: visualautomate init <name> [--template <name>]");
|
|
@@ -2361,22 +2542,22 @@ async function cmdInit(args, flags) {
|
|
|
2361
2542
|
fail(`Unknown template "${templateName}". Available: ${Object.keys(TEMPLATES).join(", ")}`);
|
|
2362
2543
|
}
|
|
2363
2544
|
const target = resolve3(process.cwd(), name);
|
|
2364
|
-
if (
|
|
2545
|
+
if (existsSync4(target)) fail(`"${name}" already exists here.`);
|
|
2365
2546
|
const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
2366
2547
|
await mkdir2(target, { recursive: true });
|
|
2367
|
-
await writeFile2(
|
|
2368
|
-
await writeFile2(
|
|
2548
|
+
await writeFile2(join4(target, "plugin.ts"), template.code);
|
|
2549
|
+
await writeFile2(join4(target, "manifest.json"), template.manifest);
|
|
2369
2550
|
await writeFile2(
|
|
2370
|
-
|
|
2551
|
+
join4(target, "plugin.json"),
|
|
2371
2552
|
JSON.stringify(
|
|
2372
2553
|
{ name: slug, version: "1.0.0", description: template.description, category: template.category },
|
|
2373
2554
|
null,
|
|
2374
2555
|
2
|
|
2375
2556
|
) + "\n"
|
|
2376
2557
|
);
|
|
2377
|
-
await writeFile2(
|
|
2558
|
+
await writeFile2(join4(target, "visualautomate.d.ts"), TYPE_DECLARATIONS);
|
|
2378
2559
|
await writeFile2(
|
|
2379
|
-
|
|
2560
|
+
join4(target, "tsconfig.json"),
|
|
2380
2561
|
JSON.stringify(
|
|
2381
2562
|
{
|
|
2382
2563
|
compilerOptions: {
|
|
@@ -2395,11 +2576,11 @@ async function cmdInit(args, flags) {
|
|
|
2395
2576
|
) + "\n"
|
|
2396
2577
|
);
|
|
2397
2578
|
await writeFile2(
|
|
2398
|
-
|
|
2579
|
+
join4(target, "test.json"),
|
|
2399
2580
|
JSON.stringify(template.testInput, null, 2) + "\n"
|
|
2400
2581
|
);
|
|
2401
2582
|
await writeFile2(
|
|
2402
|
-
|
|
2583
|
+
join4(target, "README.md"),
|
|
2403
2584
|
`# ${name}
|
|
2404
2585
|
|
|
2405
2586
|
${template.description}
|
|
@@ -2462,8 +2643,8 @@ ${bold(`\u25B8 ${target.name}`)}`);
|
|
|
2462
2643
|
const exported = loadPluginCode(code, sandboxRequire(packageLoader(target.dir)));
|
|
2463
2644
|
const plugin = typeof exported === "function" ? exported : typeof exported?.execute === "function" ? exported : exported?.default;
|
|
2464
2645
|
let outputs = [];
|
|
2465
|
-
const manifestPath =
|
|
2466
|
-
if (
|
|
2646
|
+
const manifestPath = join4(target.dir, "manifest.json");
|
|
2647
|
+
if (existsSync4(manifestPath)) {
|
|
2467
2648
|
try {
|
|
2468
2649
|
const manifest = parseJsonc(await readFile3(manifestPath, "utf8")).value;
|
|
2469
2650
|
outputs = manifest.outputs ?? [];
|
|
@@ -2512,7 +2693,7 @@ ${bold(`\u25B8 ${target.name}`)}`);
|
|
|
2512
2693
|
}
|
|
2513
2694
|
if (persist) {
|
|
2514
2695
|
const refused = await savePersisted(target.dir, result.files, result.state);
|
|
2515
|
-
say(`${dim("kept")} ${dim(`storage and state in ${relative3(process.cwd(),
|
|
2696
|
+
say(`${dim("kept")} ${dim(`storage and state in ${relative3(process.cwd(), join4(target.dir, ".visualautomate")) || ".visualautomate"}`)}`);
|
|
2516
2697
|
for (const path of refused) say(yellow(`! storage path "${path}" points outside the storage folder and was not written`));
|
|
2517
2698
|
}
|
|
2518
2699
|
if (port && outputs.length > 0 && !outputs.includes(port)) {
|
|
@@ -2541,9 +2722,9 @@ ${passed === targets.length ? green(bold(tally)) : red(bold(tally))}`);
|
|
|
2541
2722
|
return passed === targets.length;
|
|
2542
2723
|
}
|
|
2543
2724
|
function packageLoader(dir) {
|
|
2544
|
-
const fromProject = createRequire(
|
|
2725
|
+
const fromProject = createRequire(join4(dir, "noop.js"));
|
|
2545
2726
|
const imageDir = process.env.VISUALAUTOMATE_PACKAGES;
|
|
2546
|
-
const fromImage = imageDir ? createRequire(
|
|
2727
|
+
const fromImage = imageDir ? createRequire(join4(imageDir, "noop.js")) : null;
|
|
2547
2728
|
return (name) => {
|
|
2548
2729
|
try {
|
|
2549
2730
|
return fromProject(name);
|
|
@@ -2605,16 +2786,104 @@ function sayWhatRuns(targets, picked) {
|
|
|
2605
2786
|
ok(`Running ${targets.length} modules: ${targets.map((t) => t.name.replace(/^modules\//, "")).join(", ")}`);
|
|
2606
2787
|
if (!picked) ok(dim(`One of them: vsa dev ${targets[0].name.replace(/^modules\//, "")}`));
|
|
2607
2788
|
}
|
|
2789
|
+
function keepTypes() {
|
|
2790
|
+
try {
|
|
2791
|
+
return ensureRepoTypes(projectRoot(process.cwd()), VERSION);
|
|
2792
|
+
} catch {
|
|
2793
|
+
return [];
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
async function cmdTypes() {
|
|
2797
|
+
const root = projectRoot(process.cwd());
|
|
2798
|
+
const written = ensureRepoTypes(root, VERSION);
|
|
2799
|
+
if (written.length === 0) {
|
|
2800
|
+
ok(`${TYPES_FILE} and ${JSCONFIG_FILE} are already what this CLI writes.`);
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
ok(green(`\u2713 Wrote ${written.join(" and ")}`));
|
|
2804
|
+
ok(dim("Your editor now completes input, config and context. Name the type above module.exports:"));
|
|
2805
|
+
ok(dim(" /** @type {PluginModule} */"));
|
|
2806
|
+
}
|
|
2608
2807
|
async function cmdTest(args, flags) {
|
|
2609
2808
|
const targets = targetsOrFail(args, flags);
|
|
2809
|
+
keepTypes();
|
|
2610
2810
|
if (flags.docker) prepareSandbox(flags);
|
|
2611
2811
|
const passed = flags.docker ? runTargetsInDocker(targets, targets, flags) : await runTargets(targets, flags);
|
|
2612
2812
|
if (!passed) process.exitCode = 1;
|
|
2613
2813
|
}
|
|
2814
|
+
async function cmdAddProperty(args, flags) {
|
|
2815
|
+
const [type, name, description, value] = args;
|
|
2816
|
+
if (!type || !name) {
|
|
2817
|
+
fail(
|
|
2818
|
+
`What property? vsa add:property <type> <name> [description] [default]
|
|
2819
|
+
vsa add:property string apiKey "The key to call with"
|
|
2820
|
+
vsa add:property number retries "How many times to try again" 2
|
|
2821
|
+
Types: ${PROPERTY_TYPES.join(", ")}`
|
|
2822
|
+
);
|
|
2823
|
+
}
|
|
2824
|
+
if (!isPropertyType(type)) {
|
|
2825
|
+
fail(`There is no "${type}" property. Types: ${PROPERTY_TYPES.join(", ")}`);
|
|
2826
|
+
}
|
|
2827
|
+
if (!isPropertyName(name)) {
|
|
2828
|
+
fail(`"${name}" is not a name your code can read: config.${name} has to be something you can write.`);
|
|
2829
|
+
}
|
|
2830
|
+
const targets = targetsOrFail([], flags);
|
|
2831
|
+
if (targets.length > 1) {
|
|
2832
|
+
const names2 = targets.map((t) => t.name.replace(/^modules\//, ""));
|
|
2833
|
+
fail(
|
|
2834
|
+
`Which module? This repository has ${names2.join(", ")}.
|
|
2835
|
+
vsa add:property ${type} ${name} --module ${names2[0]}`
|
|
2836
|
+
);
|
|
2837
|
+
}
|
|
2838
|
+
const target = targets[0];
|
|
2839
|
+
const file2 = join4(target.dir, "manifest.json");
|
|
2840
|
+
if (!existsSync4(file2)) fail(`${target.name} has no manifest.json.`);
|
|
2841
|
+
const text = await readFile3(file2, "utf8");
|
|
2842
|
+
if (hasMember(text, "properties", name)) {
|
|
2843
|
+
fail(`${target.name} already has a property called ${name}. Change it in ${target.name}/manifest.json.`);
|
|
2844
|
+
}
|
|
2845
|
+
let property;
|
|
2846
|
+
let next;
|
|
2847
|
+
try {
|
|
2848
|
+
property = buildProperty({
|
|
2849
|
+
type,
|
|
2850
|
+
name,
|
|
2851
|
+
description,
|
|
2852
|
+
value,
|
|
2853
|
+
label: flags.label,
|
|
2854
|
+
required: flags.required === "true",
|
|
2855
|
+
options: flags.options,
|
|
2856
|
+
provider: flags.provider
|
|
2857
|
+
});
|
|
2858
|
+
next = withMember(text, "properties", name, property);
|
|
2859
|
+
} catch (error) {
|
|
2860
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
2861
|
+
}
|
|
2862
|
+
await writeFile2(file2, next, "utf8");
|
|
2863
|
+
const written = [`${target.name}/manifest.json`];
|
|
2864
|
+
const testPath = TEST_FILES.map((name2) => join4(target.dir, name2)).find((path) => existsSync4(path)) ?? join4(target.dir, TEST_FILES[0]);
|
|
2865
|
+
const sample = sampleFor(property);
|
|
2866
|
+
try {
|
|
2867
|
+
const before = existsSync4(testPath) ? await readFile3(testPath, "utf8") : `{
|
|
2868
|
+
"config": {},
|
|
2869
|
+
"input": {}
|
|
2870
|
+
}
|
|
2871
|
+
`;
|
|
2872
|
+
if (!hasMember(before, "config", name)) {
|
|
2873
|
+
await writeFile2(testPath, withMember(before, "config", name, sample), "utf8");
|
|
2874
|
+
written.push(`${target.name}/${basename3(testPath)}`);
|
|
2875
|
+
}
|
|
2876
|
+
} catch (error) {
|
|
2877
|
+
ok(yellow(` ${basename3(testPath)} was left alone: ${error instanceof Error ? error.message : String(error)}`));
|
|
2878
|
+
}
|
|
2879
|
+
ok(green(`\u2713 Added ${name} to ${written.join(" and ")}`));
|
|
2880
|
+
ok(dim(` Read it in your code as config.${name}`));
|
|
2881
|
+
}
|
|
2614
2882
|
async function cmdDev(args, flags) {
|
|
2615
2883
|
const root = process.cwd();
|
|
2616
2884
|
const picked = args[0] ?? flags.module;
|
|
2617
2885
|
let targets = targetsOrFail(args, flags);
|
|
2886
|
+
const typesWritten = keepTypes();
|
|
2618
2887
|
const inSandbox = flags.local !== "true";
|
|
2619
2888
|
if (inSandbox) prepareSandbox(flags);
|
|
2620
2889
|
const header = () => {
|
|
@@ -2625,6 +2894,7 @@ async function cmdDev(args, flags) {
|
|
|
2625
2894
|
header();
|
|
2626
2895
|
ok(dim(inSandbox ? `in ${imageFor(flags)}, tier ${flags.tier || "standard"}` : "on this machine, not in the sandbox"));
|
|
2627
2896
|
sayWhatRuns(targets, Boolean(picked));
|
|
2897
|
+
if (typesWritten.length > 0) ok(dim(`Wrote ${typesWritten.join(" and ")} \u2014 your editor now knows what context is`));
|
|
2628
2898
|
await run(targets);
|
|
2629
2899
|
const ignored = [`${sep2}node_modules${sep2}`, `${sep2}.git${sep2}`, `${sep2}dist${sep2}`, `${sep2}.visualautomate${sep2}`];
|
|
2630
2900
|
const pending = /* @__PURE__ */ new Set();
|
|
@@ -2655,7 +2925,7 @@ async function cmdDev(args, flags) {
|
|
|
2655
2925
|
};
|
|
2656
2926
|
const watcher = watch(root, { recursive: true }, (_event, name) => {
|
|
2657
2927
|
if (!name) return;
|
|
2658
|
-
const path =
|
|
2928
|
+
const path = join4(root, name.toString());
|
|
2659
2929
|
if (ignored.some((part) => `${sep2}${relative3(root, path)}${sep2}`.includes(part))) return;
|
|
2660
2930
|
pending.add(path);
|
|
2661
2931
|
if (timer) clearTimeout(timer);
|
|
@@ -2668,104 +2938,16 @@ async function cmdDev(args, flags) {
|
|
|
2668
2938
|
});
|
|
2669
2939
|
});
|
|
2670
2940
|
}
|
|
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
2941
|
function printHelp() {
|
|
2759
2942
|
console.log(`
|
|
2760
|
-
vsa \u2014 build and
|
|
2943
|
+
vsa \u2014 build and test VisualAutomate plugins
|
|
2761
2944
|
|
|
2762
|
-
vsa login sign this machine in
|
|
2763
2945
|
vsa init <name> [--template] start a plugin
|
|
2764
2946
|
vsa dev [<module>] the sandbox, and a run again on every save
|
|
2765
2947
|
vsa test [<module>] run it once, as the platform's check does
|
|
2766
|
-
vsa
|
|
2767
|
-
vsa
|
|
2768
|
-
|
|
2948
|
+
vsa types write the types your editor reads
|
|
2949
|
+
vsa add:property <type> <name> [description] [default]
|
|
2950
|
+
add a field to a module's manifest
|
|
2769
2951
|
vsa --version this CLI's version
|
|
2770
2952
|
|
|
2771
2953
|
\`va\` and \`visualautomate\` are the same command.
|
|
@@ -2777,6 +2959,15 @@ GitHub repository, and at the root of an app's repository, where every folder
|
|
|
2777
2959
|
in modules/ is run (or the one you name: vsa dev send-message). The run's settings come
|
|
2778
2960
|
from visualautomate.test.json, test.json, or --input <file>.
|
|
2779
2961
|
|
|
2962
|
+
Options for \`add:property\`:
|
|
2963
|
+
--module <name> which module, from the root of an app's repository
|
|
2964
|
+
--required the workflow will not run without it
|
|
2965
|
+
--label <text> instead of one made from the name
|
|
2966
|
+
--options <list> for select and multiselect: "Fast:fast,Thorough:thorough"
|
|
2967
|
+
--provider <name> for connection: whose account this is
|
|
2968
|
+
|
|
2969
|
+
Property types: ${PROPERTY_TYPES.join(", ")}
|
|
2970
|
+
|
|
2780
2971
|
Options for \`dev\` and \`test\`:
|
|
2781
2972
|
--module <name> the same as naming it: vsa dev send-message
|
|
2782
2973
|
--tier <name> the sandbox's size: standard, boosted, high or max
|
|
@@ -2785,9 +2976,9 @@ Options for \`dev\` and \`test\`:
|
|
|
2785
2976
|
--local \`dev\` without the sandbox, on this machine's Node
|
|
2786
2977
|
--docker \`test\` inside the sandbox, as \`dev\` does
|
|
2787
2978
|
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2979
|
+
Your plugin reaches the platform through its GitHub repository: push a commit,
|
|
2980
|
+
the tests run there, and Publish on the portal takes it from the commit that
|
|
2981
|
+
passed. Nothing here needs an account.
|
|
2791
2982
|
`);
|
|
2792
2983
|
}
|
|
2793
2984
|
function parse(argv) {
|
|
@@ -2813,28 +3004,22 @@ async function main() {
|
|
|
2813
3004
|
return;
|
|
2814
3005
|
}
|
|
2815
3006
|
switch (command) {
|
|
2816
|
-
case "login":
|
|
2817
|
-
return cmdLogin(flags);
|
|
2818
|
-
case "logout":
|
|
2819
|
-
return cmdLogout();
|
|
2820
|
-
case "whoami":
|
|
2821
|
-
return cmdWhoami(flags);
|
|
2822
3007
|
case "init":
|
|
2823
3008
|
case "new":
|
|
2824
3009
|
return cmdInit(args, flags);
|
|
2825
3010
|
case "test":
|
|
2826
3011
|
return cmdTest(args, flags);
|
|
3012
|
+
case "types":
|
|
3013
|
+
return cmdTypes();
|
|
3014
|
+
case "add:property":
|
|
3015
|
+
case "add:prop":
|
|
3016
|
+
return cmdAddProperty(args, flags);
|
|
2827
3017
|
case "dev":
|
|
2828
3018
|
case "watch":
|
|
2829
3019
|
return cmdDev(args, flags);
|
|
2830
|
-
|
|
2831
|
-
case "publish":
|
|
2832
|
-
return cmdPush(flags);
|
|
2833
|
-
// `plugin test` and `plugin push` are accepted as aliases.
|
|
3020
|
+
// `plugin test` is accepted as an alias.
|
|
2834
3021
|
case "plugin": {
|
|
2835
|
-
|
|
2836
|
-
if (sub === "test") return cmdTest(args, flags);
|
|
2837
|
-
if (sub === "push") return cmdPush(flags);
|
|
3022
|
+
if (args.shift() === "test") return cmdTest(args, flags);
|
|
2838
3023
|
printHelp();
|
|
2839
3024
|
return;
|
|
2840
3025
|
}
|
package/package.json
CHANGED