@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.
Files changed (3) hide show
  1. package/README.md +57 -16
  2. package/dist/index.js +417 -268
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,16 +1,18 @@
1
1
  # @visualautomate/cli
2
2
 
3
- Build and publish VisualAutomate plugins.
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
- ## `push`
229
+ ## Publishing
182
230
 
183
- Bundles the same code `test` ran, strips the comments out of `manifest.json`,
184
- and publishes. A version snapshot is saved, so you can roll back from the
185
- dashboard.
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, the bytes on the wire never do.
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 existsSync3, watch } from "node:fs";
7
- import { homedir } from "node:os";
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) => join(dir, file2)).find((file2) => existsSync(file2)) ?? null;
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 = join(dir, "modules");
1836
- if (!entryIn(dir) && existsSync(modulesDir) && statSync(modulesDir).isDirectory()) {
1837
- const folders = readdirSync(modulesDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync(join(modulesDir, d.name, "manifest.json"))).map((d) => d.name).sort();
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(join(modulesDir, folder), `modules/${folder}`)).filter(Boolean);
2120
+ return chosen.map((folder) => targetIn(join2(modulesDir, folder), `modules/${folder}`)).filter(Boolean);
1846
2121
  }
1847
- const target = targetIn(dir, basename(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 = basename(entry) === "index.js" && !existsSync(join(dir, "plugin.json"));
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) => join(dir, f)).find((f) => existsSync(f)) ?? null;
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 (!existsSync(path)) throw new Error(`${path} does not exist.`);
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 existsSync2 } from "node:fs";
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 join2, relative as relative2, resolve as resolve2, sep } from "node:path";
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" || existsSync2(join2(dir, PERSIST_DIR));
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 (!existsSync2(dir)) return [];
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, join2(dir, entry.name)) : Promise.resolve([join2(dir, entry.name)]))
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 = join2(dir, PERSIST_DIR, "storage");
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 = join2(dir, PERSIST_DIR, "state.json");
2056
- if (existsSync2(statePath)) {
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 = join2(dir, PERSIST_DIR, "storage");
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(dirname(target), { recursive: true });
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(join2(dir, PERSIST_DIR), { recursive: true });
2084
- await writeFile(join2(dir, PERSIST_DIR, "state.json"), JSON.stringify(Object.fromEntries(state), null, 2) + "\n");
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.5.2" : "0.0.0-dev";
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 (existsSync3(target)) fail(`"${name}" already exists here.`);
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(join3(target, "plugin.ts"), template.code);
2368
- await writeFile2(join3(target, "manifest.json"), template.manifest);
2526
+ await writeFile2(join4(target, "plugin.ts"), template.code);
2527
+ await writeFile2(join4(target, "manifest.json"), template.manifest);
2369
2528
  await writeFile2(
2370
- join3(target, "plugin.json"),
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(join3(target, "visualautomate.d.ts"), TYPE_DECLARATIONS);
2536
+ await writeFile2(join4(target, "visualautomate.d.ts"), TYPE_DECLARATIONS);
2378
2537
  await writeFile2(
2379
- join3(target, "tsconfig.json"),
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
- join3(target, "test.json"),
2557
+ join4(target, "test.json"),
2399
2558
  JSON.stringify(template.testInput, null, 2) + "\n"
2400
2559
  );
2401
2560
  await writeFile2(
2402
- join3(target, "README.md"),
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 = join3(target.dir, "manifest.json");
2466
- if (existsSync3(manifestPath)) {
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(), join3(target.dir, ".visualautomate")) || ".visualautomate"}`)}`);
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(join3(dir, "noop.js"));
2703
+ const fromProject = createRequire(join4(dir, "noop.js"));
2545
2704
  const imageDir = process.env.VISUALAUTOMATE_PACKAGES;
2546
- const fromImage = imageDir ? createRequire(join3(imageDir, "noop.js")) : null;
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 = join3(root, name.toString());
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 publish VisualAutomate plugins
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 push publish it
2767
- vsa whoami which account this is
2768
- vsa logout forget the token
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
- Environment (for CI, where there is no browser):
2789
- VISUALAUTOMATE_API_TOKEN use instead of \`login\`
2790
- VISUALAUTOMATE_API_URL a different installation
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
- case "push":
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
- const sub = args.shift();
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@visualautomate/cli",
3
- "version": "0.5.2",
4
- "description": "Build and publish VisualAutomate plugins.",
3
+ "version": "0.6.0",
4
+ "description": "Build and test VisualAutomate plugins.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {