@cloud-cli/on 1.38.0 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/on.js CHANGED
@@ -313,16 +313,22 @@ var de = class extends l {
313
313
  }
314
314
  reload() {}
315
315
  get(e) {
316
+ return this.secrets.get(e)?.value;
317
+ }
318
+ getValue(e) {
316
319
  return this.secrets.get(e);
317
320
  }
318
321
  getAll() {
319
- return Object.fromEntries(this.secrets);
322
+ return Object.fromEntries([...this.secrets].map(([e, t]) => [e, t.value]));
320
323
  }
321
324
  replace(e) {
322
- this.secrets = new Map(Object.entries(e));
325
+ this.secrets = new Map(Object.entries(e).map(([e, t]) => [e, typeof t == "string" ? {
326
+ value: t,
327
+ encoding: "utf8"
328
+ } : t]));
323
329
  }
324
330
  redactText(e) {
325
- let t = Array.from(this.secrets.values()).filter((e) => e.length > 3).slice().sort((e, t) => t.length - e.length);
331
+ let t = Array.from(this.secrets.values()).map((e) => e.value).filter((e) => e.length > 3).slice().sort((e, t) => t.length - e.length);
326
332
  for (let n of t) e = e.replaceAll(n, "****");
327
333
  return e;
328
334
  }
@@ -3805,8 +3811,7 @@ var Yn = {
3805
3811
  JSON: {
3806
3812
  parse: (e) => JSON.parse(e),
3807
3813
  stringify: (e) => JSON.stringify(e, null, 2)
3808
- },
3809
- Object: { keys: (e) => Object.keys(e) }
3814
+ }
3810
3815
  };
3811
3816
  function Xn(t) {
3812
3817
  let n = i.resolve(t), r = (...e) => {
@@ -3977,23 +3982,35 @@ function er() {
3977
3982
  var tr = class {
3978
3983
  key;
3979
3984
  async init() {
3980
- await C.exec("CREATE TABLE IF NOT EXISTS secrets (\n name TEXT PRIMARY KEY, ciphertext TEXT NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n )");
3985
+ await C.exec("CREATE TABLE IF NOT EXISTS secrets (\n name TEXT PRIMARY KEY, ciphertext TEXT NOT NULL,\n encoding TEXT NOT NULL DEFAULT 'utf8',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n )"), await C.run("ALTER TABLE secrets ADD COLUMN encoding TEXT NOT NULL DEFAULT 'utf8'").catch(() => {});
3981
3986
  }
3982
3987
  encryptionKey() {
3983
3988
  return this.key ||= er();
3984
3989
  }
3985
- async set(e, t) {
3990
+ async set(e, t, n = "utf8") {
3986
3991
  if (!/^[A-Z][A-Z0-9_]*$/.test(e)) throw Error("Secret names must be uppercase letters, numbers, and underscores");
3987
- let n = u.randomBytes(12), r = u.createCipheriv("aes-256-gcm", this.encryptionKey(), n), i = Buffer.concat([r.update(t, "utf8"), r.final()]), a = Buffer.concat([
3988
- n,
3989
- r.getAuthTag(),
3990
- i
3992
+ if (n !== "utf8" && n !== "base64") throw Error("Secret encoding must be utf8 or base64");
3993
+ let r = u.randomBytes(12), i = u.createCipheriv("aes-256-gcm", this.encryptionKey(), r), a = n === "base64" ? Buffer.from(t, "base64") : Buffer.from(t, "utf8"), o = Buffer.concat([i.update(a), i.final()]), s = Buffer.concat([
3994
+ r,
3995
+ i.getAuthTag(),
3996
+ o
3991
3997
  ]).toString("base64");
3992
- await C.run("INSERT INTO secrets (name, ciphertext) VALUES (?, ?)\n ON CONFLICT(name) DO UPDATE SET ciphertext = excluded.ciphertext, updated_at = CURRENT_TIMESTAMP", [e, a]);
3998
+ await C.run("INSERT INTO secrets (name, ciphertext, encoding) VALUES (?, ?, ?)\n ON CONFLICT(name) DO UPDATE SET ciphertext = excluded.ciphertext, encoding = excluded.encoding, updated_at = CURRENT_TIMESTAMP", [
3999
+ e,
4000
+ s,
4001
+ n
4002
+ ]);
3993
4003
  }
3994
4004
  async getAll() {
3995
- let e = await C.all("SELECT name, ciphertext FROM secrets ORDER BY name");
3996
- return Object.fromEntries(e.map((e) => [e.name, this.decrypt(e.ciphertext)]));
4005
+ let e = await C.all("SELECT name, ciphertext, encoding FROM secrets ORDER BY name");
4006
+ return Object.fromEntries(e.map((e) => [e.name, this.decrypt(e.ciphertext, e.encoding)]));
4007
+ }
4008
+ async getAllForJob() {
4009
+ let e = await C.all("SELECT name, ciphertext, encoding FROM secrets ORDER BY name");
4010
+ return Object.fromEntries(e.map((e) => [e.name, {
4011
+ value: this.decrypt(e.ciphertext, e.encoding),
4012
+ encoding: e.encoding || "utf8"
4013
+ }]));
3997
4014
  }
3998
4015
  async names() {
3999
4016
  return (await C.all("SELECT name FROM secrets ORDER BY name")).map((e) => e.name);
@@ -4002,9 +4019,11 @@ var tr = class {
4002
4019
  let t = await C.run("DELETE FROM secrets WHERE name = ?", [e]);
4003
4020
  return Number(t?.changes || 0) > 0;
4004
4021
  }
4005
- decrypt(e) {
4006
- let t = Buffer.from(e, "base64"), n = u.createDecipheriv("aes-256-gcm", this.encryptionKey(), t.subarray(0, 12));
4007
- return n.setAuthTag(t.subarray(12, 28)), Buffer.concat([n.update(t.subarray(28)), n.final()]).toString("utf8");
4022
+ decrypt(e, t = "utf8") {
4023
+ let n = Buffer.from(e, "base64"), r = u.createDecipheriv("aes-256-gcm", this.encryptionKey(), n.subarray(0, 12));
4024
+ r.setAuthTag(n.subarray(12, 28));
4025
+ let i = Buffer.concat([r.update(n.subarray(28)), r.final()]);
4026
+ return t === "base64" ? i.toString("base64") : i.toString("utf8");
4008
4027
  }
4009
4028
  }, nr = class {
4010
4029
  config;
@@ -5551,7 +5570,7 @@ W.use = Li, W.walkTokens = function(e, t) {
5551
5570
  }, W.parseInline = Ii.parseInline, W.Parser = Pi, W.parser = Pi.parse, W.Renderer = Mi, W.TextRenderer = Ni, W.Lexer = ji, W.lexer = ji.lex, W.Tokenizer = Ai, W.Hooks = Fi, W.parse = W, W.options, W.setOptions, W.walkTokens, W.parseInline, Pi.parse, ji.lex;
5552
5571
  //#endregion
5553
5572
  //#region docs/workflow-syntax.md?raw
5554
- var Ri = "# Workflow Documentation\n\nThis is the living documentation for workflow authors. It is organized using the four Diataxis modes:\n\n- **Tutorials** teach the shortest path to a working workflow.\n- **How-to guides** solve focused tasks.\n- **Reference** documents the accepted syntax.\n- **Explanation** describes execution and security decisions.\n\n## Tutorials\n\n### Your First Workflow\n\nCreate a draft in the authenticated workflow editor, or send the YAML to the API:\n\n```yaml\nname: Hello\n\non:\n generic: {}\n\nsteps:\n - id: greet\n name: Greet\n run: echo \"Hello ${inputs.name}\"\n```\n\nSave it, validate it, and publish it. Only published and enabled workflows receive webhook or scheduled events.\n\nThe `generic` provider accepts a JSON request body as `inputs`. For example:\n\n```bash\ncurl -X POST http://localhost:11235/webhooks/generic \\\n -H 'content-type: application/json' \\\n --data '{\"name\":\"Ada\"}'\n```\n\n### Build a GitHub Workflow\n\n```yaml\nname: Build\n\non:\n github:\n events: [push]\n owner: cloud-cli\n repo: on\n branches: [main]\n\nsteps:\n - id: checkout\n name: Checkout\n env:\n clone_url: ${inputs.clone_url}\n commit_sha: ${inputs.commit_sha}\n run: |\n git clone --depth 1 \"$clone_url\" .\n git checkout \"$commit_sha\"\n\n - id: test\n name: Test\n run: pnpm install && pnpm test\n```\n\nGitHub webhooks must be signed with the configured `GITHUB_WEBHOOK_SECRET`. The preprocessor exposes normalized inputs including `event`, `owner`, `repo`, `branch`, `tag`, `ref`, `full_name`, `clone_url`, `commit_sha`, `author`, `action`, `changes`, and the original `raw` body.\n\n## How-To Guides\n\n### Reference Another Environment Value\n\nEnvironment entries are evaluated from top to bottom. Earlier values are available through `env` to later values:\n\n```yaml\nenv:\n image: ${inputs.repo}\n image_tag: ${env.image}:latest\n artifact_name: ${env.image_tag}-${inputs.commit_sha}\n```\n\nWorkflow-level values are available to every step. Step-level `env` values are evaluated after workflow-level values and can override them for that step.\n\nUse `${inputs.name}`, `${env.NAME}`, `${secrets.NAME}`, and `${steps.step_id.outputs.value}` where the value is available. A value without `${...}` remains literal text.\n\n### Run a Container with a Host Volume\n\nUse `image` with `volumes`. A volume is passed directly to `docker run` as a `-v` mapping:\n\n```yaml\nsteps:\n - id: sign\n name: Sign release\n image: alpine:latest\n volumes:\n - /var/lib/runner/secrets-store:/secrets:ro\n run: |\n ./sign.sh /secrets/signing.key dist/release.tar.gz\n```\n\nThe source path is on the worker host. The destination path is inside the container. Use absolute host paths when a specific host directory is required. The workflow must be trusted because volume mappings can expose host data to a container.\n\nA single string is also accepted:\n\n```yaml\nvolumes: /var/lib/runner/secrets-store:/secrets:ro\n```\n\n### Pass Additional Docker Arguments\n\nUse `dockerArgs` for additional arguments to `docker run`, before the image name:\n\n```yaml\nsteps:\n - id: build\n image: docker:cli\n dockerArgs:\n - --network=host\n - --user=1000:1000\n run: docker build -t example:latest .\n```\n\nArguments are not interpreted or shell-expanded by the runner. They are passed as individual Docker CLI arguments.\n\n### Use a Matrix\n\nKeep the matrix in the workflow definition:\n\n```yaml\nname: Package\n\nmatrix:\n node: [18, 20]\n os: [ubuntu, alpine]\n\ntags:\n - '${matrix.os}'\n\non:\n generic: {}\n\nenv:\n node_version: ${env.MATRIX_NODE}\n\nsteps:\n - id: package\n image: node:${env.MATRIX_NODE}\n run: npm pack\n```\n\nThe runner creates one job for every Cartesian combination. The example creates four jobs. Each variant receives `MATRIX_NODE` and `MATRIX_OS` environment variables. Matrix expressions in `tags` are resolved before enqueueing, so each job can target a different runner. Tags remain an AND requirement within each job.\n\nIf a matching runner is offline before claiming its matrix job, the job remains pending and is claimed when that runner reconnects. A job already claimed by a worker is recovered as stale after one hour if the worker process does not complete it.\n\n### Add a Scheduled Trigger\n\n```yaml\non:\n schedule:\n - id: nightly\n cron: '0 2 * * *'\n timezone: Europe/Berlin\n```\n\nCron expressions have five fields: minute, hour, day of month, month, and day of week. The scheduler uses the declared IANA timezone.\n\nSolar triggers use `sunrise` or `sunset` and geographic coordinates:\n\n```yaml\non:\n solar:\n - id: morning\n event: sunrise\n latitude: 52.52\n longitude: 13.405\n offset: +15m\n```\n\n### Use Secrets\n\nCreate a secret in the management UI or with the API, then reference it in environment values:\n\n```yaml\nsteps:\n - id: publish\n env:\n npm_token: ${secrets.NPM_TOKEN}\n run: npm publish\n```\n\nSecret values are delivered to a worker after it claims the job. Do not write them into reports or artifacts.\n\n### Persist Artifacts and Caches\n\nArtifacts are captured from the workspace after a successful run:\n\n```yaml\nartifacts:\n paths:\n - dist\n - reports/**/*.json\n```\n\nCaches are restored before steps start and saved after a successful run:\n\n```yaml\ncache:\n key: npm-${inputs.repo}-${inputs.branch}\n paths:\n - node_modules\n```\n\nConfigure a shared FileBin backend on the server and workers with `RUNNER_FILE_API_URL`, `RUNNER_FILE_BIN`, and optionally `RUNNER_FILE_PASSWORD`. Create a bin with `POST /bin`, then configure its ID. Without FileBin configuration, the runner uses the central database fallback. Cache keys are scoped by workflow; artifacts are scoped by job. Stored collections are limited to 50 MiB per capture.\n\nAdministrators can download a captured artifact with `GET /api/runs/:jobId/artifacts/:path`.\n\nPopulate workspace files from managed secrets before steps run:\n\n```yaml\nsecretFiles:\n .config/signing.key: SIGNING_KEY\n```\n\nSecret files are created with owner-only permissions in the worker workspace. They must be handled according to the worker storage lifecycle and should not be included in artifacts or caches.\n\n### Configure Workflow Plugins\n\nEnable status updates for an individual GitHub workflow by declaring a plugin and mapping its secret inputs through the existing server-managed secrets:\n\n```yaml\nplugins:\n - name: github-status\n secrets:\n GITHUB_TOKEN: ${secrets.GITHUB_TOKEN}\n context: ci/build\n```\n\nThe worker uses that secret for `pending`, `success`, `failure`, or `error` commit statuses. The token is never stored in workflow YAML or job reports. The workflow must have GitHub `owner`, `repo`, and `commit_sha` inputs; otherwise no status request is made.\n\nPlugin names are resolved through the server's workflow plugin registry. `github-status` is built in. External integrations can register a factory during runner configuration with `registerWorkflowPlugin(name, factory)` before workers start.\n\n## Reference\n\n### Top-Level Workflow Fields\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `name` | string | Required human-readable workflow name shown in the UI and reports. |\n| `id` | string | Optional identifier used when deriving an ID outside the management API. The management API ID is authoritative after creation. |\n| `on` | object | Required trigger configuration. |\n| `env` | object | Workflow environment values, evaluated in declaration order. |\n| `matrix` | object | Matrix dimensions. Each value is an array of strings, numbers, or booleans. |\n| `concurrency` | object | Optional concurrency group and cancellation policy. |\n| `tags` | string[] | Worker capability tags required by the job. |\n| `artifacts` | object | Workspace paths captured after successful runs. |\n| `cache` | object | Keyed workspace paths restored before and saved after successful runs. |\n| `secretFiles` | object | Workspace-relative file paths mapped to secret names. |\n| `plugins` | object[] | Optional per-workflow plugin declarations and secret mappings. |\n| `steps` | object[] | Required non-empty ordered list of steps. |\n| `retries` | integer | Additional attempts for a failed step. Must be zero or greater. |\n\n`includes` and workflow-to-workflow imports are not supported. Workflow definitions are stored as complete database revisions. Reusable behavior should be implemented through the planned step reuse mechanism rather than filesystem imports.\n\n### Triggers\n\nThe first non-`schedule`/`solar` key in `on` is the provider trigger. Supported providers include `generic` and `github`.\n\n#### GitHub Trigger Filters\n\n| Field | Type | Behavior |\n| --- | --- | --- |\n| `events` | string[] | Exact event names such as `push` or `pull_request`. |\n| `owner` | string or string[] | Exact repository owner. |\n| `repo` | string or string[] | Exact repository name. |\n| `name` | string or string[] | Exact `owner/repo` name. |\n| `branches` | string or string[] | Glob patterns matching normalized branches or tags. |\n| `refs` | string or string[] | Alias for branch/ref matching. |\n| `tag` | boolean | Requires a tag push when true, a non-tag event when false. |\n| `tags` | string[] | Regular expressions matched against the tag value. |\n| `paths` | string[] | Glob patterns matched against changed files. |\n| `if` | string or string[] | Safe JavaScript condition evaluated after provider filters. |\n\nConfigured fields are combined with AND. Multiple values within a field are combined with OR. Negated `!value` entries are supported by exact-value filters.\n\n#### Schedule Trigger\n\n```yaml\nschedule:\n - id: hourly\n cron: '0 * * * *'\n timezone: UTC\n```\n\n#### Solar Trigger\n\n```yaml\nsolar:\n - id: sunrise-build\n event: sunrise\n latitude: 52.52\n longitude: 13.405\n timezone: Europe/Berlin\n offset: -15m\n```\n\n### Environment and Expressions\n\nInterpolated strings use standard JavaScript template expressions inside `${...}`. The safe evaluator supports literals, property access, arithmetic, comparisons, boolean operators, ternaries, arrays, objects, and these helpers:\n\n```text\nString(value)\nNumber(value)\nBoolean(value)\nJSON.parse(value)\nJSON.stringify(value)\nObject.keys(value)\n```\n\nAvailable context objects are:\n\n| Object | Available data |\n| --- | --- |\n| `inputs` | Trigger payload and normalized provider inputs. |\n| `env` | Runner environment plus earlier workflow or step environment entries. |\n| `secrets` | Decrypted job-scoped secrets. |\n| `steps` | Earlier step statuses, exit codes, and outputs. |\n| `files` | Workspace-scoped `exists`, `readFile`, and `join` helpers for `eval:` steps. |\n\nExpressions cannot access JavaScript constructors or prototypes. Shell commands are not expression-evaluated; only declared interpolation fields are evaluated.\n\n### Steps\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `id` | string | Stable identifier used by `steps.<id>`. |\n| `name` | string | Display name. Defaults to `id`. |\n| `run` | string | Shell command or script. Mutually exclusive with `eval`. |\n| `eval` | string | In-process safe expression. Mutually exclusive with `run`. |\n| `env` | object | Step environment, evaluated after workflow environment. |\n| `image` | string | Docker image. Selects container execution for the step. |\n| `volumes` | string or string[] | Docker `-v` mappings. |\n| `dockerArgs` | string[] | Additional raw arguments for `docker run`. |\n| `timeoutMs` | positive integer | Maximum step runtime. Defaults to 30 seconds. |\n| `if` | string | Safe condition. False conditions produce a skipped step. |\n\n`run` steps execute through the selected worker driver. With `image`, the command runs as `sh -c` inside Docker. Without `image`, it runs through the worker shell. `eval` steps run inside the worker process and can produce structured outputs for later steps.\n\n### Management and API\n\nWorkflow definitions are drafted, validated, published, enabled, and deleted through the authenticated management UI or these endpoints:\n\n```text\nPOST /api/workflows/validate\nGET /api/workflows\nGET /api/workflows/:id\nPUT /api/workflows/:id\nPOST /api/workflows/:id/publish\nDELETE /api/workflows/:id\n```\n\nOnly published workflows with `enabled: true` are matched for incoming triggers. Saving a workflow creates a new immutable revision. Existing jobs continue to use the revision recorded when they were created.\n\n## Explanation\n\n### Why Workflow YAML Lives in the Database\n\nThe server stores source YAML and normalized revisions in the database. Workers are stateless: they fetch the referenced revision when claiming a job and do not read workflow files from their local filesystem. This keeps every worker on the same definition and makes published revisions reproducible.\n\n### Why Matrix Expansion Happens at Trigger Time\n\nThe stored revision remains one canonical workflow. When a trigger matches, the server creates one queue job per matrix combination. The payload records the matrix context, and the worker resolves that same variant before execution. This avoids storing duplicate workflow revisions while keeping each job independently observable.\n\n### Docker Volumes and Trust\n\n`volumes` and `dockerArgs` are intentionally powerful options. A host volume can expose credentials or signing material, and Docker arguments can change networking, users, capabilities, or isolation. Only trusted administrators should publish workflows using these fields. The runner does not copy, inspect, or redact files mounted into containers.\n\n### Reuse and Imports\n\nFilesystem imports were removed because workers no longer load workflow files. Future step reuse should operate on database-owned definitions or explicit reusable step mechanisms, keeping revisioning and authorization visible to the control plane.\n", zi = "\n <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <circle cx=\"12\" cy=\"12\" r=\"9\"></circle>\n <path d=\"M9.6 9a2.5 2.5 0 1 1 4.2 1.8c-1.2 1-1.8 1.4-1.8 2.7\"></path>\n <path d=\"M12 17h.01\"></path>\n </svg>", Bi = "\n :root { color-scheme: dark; }\n * { box-sizing: border-box; }\n body { margin: 0; background: #030712; color: #e5e7eb; font-family: ui-sans-serif, system-ui, sans-serif; }\n main { max-width: 78rem; margin: 0 auto; padding: 2rem 1rem 5rem; }\n article { max-width: 54rem; margin: 0 auto; line-height: 1.7; }\n h1, h2, h3 { color: #f9fafb; line-height: 1.2; letter-spacing: -0.02em; }\n h1 { font-size: 2.25rem; margin: 0 0 1rem; }\n h2 { border-top: 1px solid #1f2937; margin-top: 3rem; padding-top: 1.5rem; font-size: 1.6rem; }\n h3 { margin-top: 2rem; font-size: 1.15rem; }\n p, li { color: #cbd5e1; }\n a { color: #93c5fd; }\n code { border-radius: .35rem; background: #111827; color: #bfdbfe; padding: .12rem .35rem; font-size: .9em; }\n pre { overflow-x: auto; border: 1px solid #1f2937; border-radius: .75rem; background: #0b1120; padding: 1rem; }\n pre code { background: transparent; padding: 0; color: #d1d5db; }\n table { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; margin: 1rem 0; }\n th, td { border-bottom: 1px solid #1f2937; padding: .65rem .75rem; text-align: left; vertical-align: top; }\n th { color: #f9fafb; background: #111827; }\n blockquote { border-left: 3px solid #6366f1; margin-left: 0; padding-left: 1rem; color: #94a3b8; }\n .topbar { display: flex; justify-content: space-between; gap: 1rem; align-items: center; max-width: 78rem; margin: 0 auto; padding: 1rem; }\n .back { display: inline-flex; align-items: center; gap: .45rem; color: #94a3b8; text-decoration: none; font-size: .85rem; }\n .back:hover, .back:focus-visible { color: #f9fafb; }\n .help-layout { display: grid; grid-template-columns: minmax(0, 54rem); gap: 3rem; justify-content: center; }\n .toc { display: none; }\n .toc nav { position: sticky; top: 1.25rem; max-height: calc(100vh - 2.5rem); overflow-y: auto; border-left: 1px solid #1f2937; padding-left: 1rem; }\n .toc-title { margin: 0 0 .65rem; color: #f9fafb; font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }\n .toc ul { display: grid; gap: .15rem; list-style: none; margin: 0; padding: 0; }\n .toc a { display: block; border-radius: .35rem; color: #94a3b8; font-size: .8rem; line-height: 1.35; padding: .3rem .45rem; text-decoration: none; }\n .toc a:hover, .toc a:focus-visible { background: #111827; color: #dbeafe; outline: none; }\n .toc-depth-2 { padding-left: .9rem !important; }\n .toc-depth-3 { padding-left: 1.8rem !important; font-size: .75rem !important; }\n @media (min-width: 1024px) { .help-layout { grid-template-columns: minmax(0, 54rem) 16rem; justify-content: center; } .toc { display: block; } }\n @media (min-width: 640px) { main { padding: 3rem 1.5rem 6rem; } }\n";
5573
+ var Ri = "# Workflow Documentation\n\nThis is the living documentation for workflow authors. It is organized using the four Diataxis modes:\n\n- **Tutorials** teach the shortest path to a working workflow.\n- **How-to guides** solve focused tasks.\n- **Reference** documents the accepted syntax.\n- **Explanation** describes execution and security decisions.\n\n## Tutorials\n\n### Your First Workflow\n\nCreate a draft in the authenticated workflow editor, or send the YAML to the API:\n\n```yaml\nname: Hello\n\non:\n generic: {}\n\nsteps:\n - id: greet\n name: Greet\n run: echo \"Hello ${inputs.name}\"\n```\n\nSave it, validate it, and publish it. Only published and enabled workflows receive webhook or scheduled events.\n\nThe `generic` provider accepts a JSON request body as `inputs`. For example:\n\n```bash\ncurl -X POST http://localhost:11235/webhooks/generic \\\n -H 'content-type: application/json' \\\n --data '{\"name\":\"Ada\"}'\n```\n\n### Build a GitHub Workflow\n\n```yaml\nname: Build\n\non:\n github:\n events: [push]\n owner: cloud-cli\n repo: on\n branches: [main]\n\nsteps:\n - id: checkout\n name: Checkout\n env:\n clone_url: ${inputs.clone_url}\n commit_sha: ${inputs.commit_sha}\n run: |\n git clone --depth 1 \"$clone_url\" .\n git checkout \"$commit_sha\"\n\n - id: test\n name: Test\n run: pnpm install && pnpm test\n```\n\nGitHub webhooks must be signed with the configured `GITHUB_WEBHOOK_SECRET`. The preprocessor exposes normalized inputs including `event`, `owner`, `repo`, `branch`, `tag`, `ref`, `full_name`, `clone_url`, `commit_sha`, `author`, `action`, `changes`, and the original `raw` body.\n\n## How-To Guides\n\n### Reference Another Environment Value\n\nEnvironment entries are evaluated from top to bottom. Earlier values are available through `env` to later values:\n\n```yaml\nenv:\n image: ${inputs.repo}\n image_tag: ${env.image}:latest\n artifact_name: ${env.image_tag}-${inputs.commit_sha}\n```\n\nWorkflow-level values are available to every step. Step-level `env` values are evaluated after workflow-level values and can override them for that step.\n\nUse `${inputs.name}`, `${env.NAME}`, `${secrets.NAME}`, and `${steps.step_id.outputs.value}` where the value is available. A value without `${...}` remains literal text.\n\n### Run a Container with a Host Volume\n\nUse `image` with `volumes`. A volume is passed directly to `docker run` as a `-v` mapping:\n\n```yaml\nsteps:\n - id: sign\n name: Sign release\n image: alpine:latest\n volumes:\n - /var/lib/runner/secrets-store:/secrets:ro\n run: |\n ./sign.sh /secrets/signing.key dist/release.tar.gz\n```\n\nThe source path is on the worker host. The destination path is inside the container. Use absolute host paths when a specific host directory is required. The workflow must be trusted because volume mappings can expose host data to a container.\n\nA single string is also accepted:\n\n```yaml\nvolumes: /var/lib/runner/secrets-store:/secrets:ro\n```\n\n### Pass Additional Docker Arguments\n\nUse `dockerArgs` for additional arguments to `docker run`, before the image name:\n\n```yaml\nsteps:\n - id: build\n image: docker:cli\n dockerArgs:\n - --network=host\n - --user=1000:1000\n run: docker build -t example:latest .\n```\n\nArguments are not interpreted or shell-expanded by the runner. They are passed as individual Docker CLI arguments.\n\n### Use a Matrix\n\nKeep the matrix in the workflow definition:\n\n```yaml\nname: Package\n\nmatrix:\n node: [18, 20]\n os: [ubuntu, alpine]\n\ntags:\n - '${matrix.os}'\n\non:\n generic: {}\n\nenv:\n node_version: ${env.MATRIX_NODE}\n\nsteps:\n - id: package\n image: node:${env.MATRIX_NODE}\n run: npm pack\n```\n\nThe runner creates one job for every Cartesian combination. The example creates four jobs. Each variant receives `MATRIX_NODE` and `MATRIX_OS` environment variables. Matrix expressions in `tags` are resolved before enqueueing, so each job can target a different runner. Tags remain an AND requirement within each job.\n\nIf a matching runner is offline before claiming its matrix job, the job remains pending and is claimed when that runner reconnects. A job already claimed by a worker is recovered as stale after one hour if the worker process does not complete it.\n\n### Add a Scheduled Trigger\n\n```yaml\non:\n schedule:\n - id: nightly\n cron: '0 2 * * *'\n timezone: Europe/Berlin\n```\n\nCron expressions have five fields: minute, hour, day of month, month, and day of week. The scheduler uses the declared IANA timezone.\n\nSolar triggers use `sunrise` or `sunset` and geographic coordinates:\n\n```yaml\non:\n solar:\n - id: morning\n event: sunrise\n latitude: 52.52\n longitude: 13.405\n offset: +15m\n```\n\n### Use Secrets\n\nCreate a secret in the management UI or with the API, then reference it in environment values:\n\n```yaml\nsteps:\n - id: publish\n env:\n npm_token: ${secrets.NPM_TOKEN}\n run: npm publish\n```\n\nSecret values are delivered to a worker after it claims the job. Do not write them into reports or artifacts.\n\nManaged secrets can be text or binary. Use the management UI's file picker for binary values, or send base64 through the API:\n\n```json\n{\n \"value\": \"<base64 file bytes>\",\n \"encoding\": \"base64\",\n \"originalName\": \"release.keystore\"\n}\n```\n\nBinary values are encrypted at rest and are decoded only when written through a file secret mapping. They should not be injected into environment variables.\n\n### Persist Artifacts and Caches\n\nArtifacts are captured from the workspace after a successful run:\n\n```yaml\nartifacts:\n paths:\n - dist\n - reports/**/*.json\n```\n\nCaches are restored before steps start and saved after a successful run:\n\n```yaml\ncache:\n key: npm-${inputs.repo}-${inputs.branch}\n paths:\n - node_modules\n```\n\nConfigure a shared FileBin backend on the server and workers with `RUNNER_FILE_API_URL`, `RUNNER_FILE_BIN`, and optionally `RUNNER_FILE_PASSWORD`. Create a bin with `POST /bin`, then configure its ID. Without FileBin configuration, the runner uses the central database fallback. Cache keys are scoped by workflow; artifacts are scoped by job. Stored collections are limited to 50 MiB per capture.\n\nAdministrators can download a captured artifact with `GET /api/runs/:jobId/artifacts/:path`.\n\nPopulate workspace files from managed secrets before steps run:\n\n```yaml\nsecretFiles:\n .config/signing.key: SIGNING_KEY\n```\n\nSecret files are created with owner-only permissions in the worker workspace. They must be handled according to the worker storage lifecycle and should not be included in artifacts or caches.\n\n### Configure Workflow Plugins\n\nEnable status updates for an individual GitHub workflow by declaring a plugin and mapping its secret inputs through the existing server-managed secrets:\n\n```yaml\nplugins:\n - name: github-status\n secrets:\n GITHUB_TOKEN: ${secrets.GITHUB_TOKEN}\n context: ci/build\n```\n\nThe worker uses that secret for `pending`, `success`, `failure`, or `error` commit statuses. The token is never stored in workflow YAML or job reports. The workflow must have GitHub `owner`, `repo`, and `commit_sha` inputs; otherwise no status request is made.\n\nPlugin names are resolved through the server's workflow plugin registry. `github-status` is built in. External integrations can register a factory during runner configuration with `registerWorkflowPlugin(name, factory)` before workers start.\n\n## Reference\n\n### Top-Level Workflow Fields\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `name` | string | Required human-readable workflow name shown in the UI and reports. |\n| `id` | string | Optional identifier used when deriving an ID outside the management API. The management API ID is authoritative after creation. |\n| `on` | object | Required trigger configuration. |\n| `env` | object | Workflow environment values, evaluated in declaration order. |\n| `matrix` | object | Matrix dimensions. Each value is an array of strings, numbers, or booleans. |\n| `concurrency` | object | Optional concurrency group and cancellation policy. |\n| `tags` | string[] | Worker capability tags required by the job. |\n| `artifacts` | object | Workspace paths captured after successful runs. |\n| `cache` | object | Keyed workspace paths restored before and saved after successful runs. |\n| `secretFiles` | object | Workspace-relative file paths mapped to secret names. |\n| `plugins` | object[] | Optional per-workflow plugin declarations and secret mappings. |\n| `steps` | object[] | Required non-empty ordered list of steps. |\n| `retries` | integer | Additional attempts for a failed step. Must be zero or greater. |\n\n`includes` and workflow-to-workflow imports are not supported. Workflow definitions are stored as complete database revisions. Reusable behavior should be implemented through the planned step reuse mechanism rather than filesystem imports.\n\n### Triggers\n\nThe first non-`schedule`/`solar` key in `on` is the provider trigger. Supported providers include `generic` and `github`.\n\n#### GitHub Trigger Filters\n\n| Field | Type | Behavior |\n| --- | --- | --- |\n| `events` | string[] | Exact event names such as `push` or `pull_request`. |\n| `owner` | string or string[] | Exact repository owner. |\n| `repo` | string or string[] | Exact repository name. |\n| `name` | string or string[] | Exact `owner/repo` name. |\n| `branches` | string or string[] | Glob patterns matching normalized branches or tags. |\n| `refs` | string or string[] | Alias for branch/ref matching. |\n| `tag` | boolean | Requires a tag push when true, a non-tag event when false. |\n| `tags` | string[] | Regular expressions matched against the tag value. |\n| `paths` | string[] | Glob patterns matched against changed files. |\n| `if` | string or string[] | Safe JavaScript condition evaluated after provider filters. |\n\nConfigured fields are combined with AND. Multiple values within a field are combined with OR. Negated `!value` entries are supported by exact-value filters.\n\n#### Schedule Trigger\n\n```yaml\nschedule:\n - id: hourly\n cron: '0 * * * *'\n timezone: UTC\n```\n\n#### Solar Trigger\n\n```yaml\nsolar:\n - id: sunrise-build\n event: sunrise\n latitude: 52.52\n longitude: 13.405\n timezone: Europe/Berlin\n offset: -15m\n```\n\n### Environment and Expressions\n\nInterpolated strings use standard JavaScript template expressions inside `${...}`. The safe evaluator supports literals, property access, arithmetic, comparisons, boolean operators, ternaries, arrays, objects, and these helpers:\n\n```text\nString(value)\nNumber(value)\nBoolean(value)\nJSON.parse(value)\nJSON.stringify(value)\n```\n\nAvailable context objects are:\n\n| Object | Available data |\n| --- | --- |\n| `inputs` | Trigger payload and normalized provider inputs. |\n| `env` | Runner environment plus earlier workflow or step environment entries. |\n| `secrets` | Decrypted job-scoped secrets. |\n| `steps` | Earlier step statuses, exit codes, and outputs. |\n| `files` | Workspace-scoped `exists`, `readFile`, and `join` helpers for `eval:` steps. |\n\nExpressions cannot access JavaScript constructors or prototypes. Shell commands are not expression-evaluated; only declared interpolation fields are evaluated.\n\n### Steps\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `id` | string | Stable identifier used by `steps.<id>`. |\n| `name` | string | Display name. Defaults to `id`. |\n| `run` | string | Shell command or script. Mutually exclusive with `eval`. |\n| `eval` | string | In-process safe expression. Mutually exclusive with `run`. |\n| `env` | object | Step environment, evaluated after workflow environment. |\n| `image` | string | Docker image. Selects container execution for the step. |\n| `volumes` | string or string[] | Docker `-v` mappings. |\n| `dockerArgs` | string[] | Additional raw arguments for `docker run`. |\n| `timeoutMs` | positive integer | Maximum step runtime. Defaults to 30 seconds. |\n| `if` | string | Safe condition. False conditions produce a skipped step. |\n\n`run` steps execute through the selected worker driver. With `image`, the command runs as `sh -c` inside Docker. Without `image`, it runs through the worker shell. `eval` steps run inside the worker process and can produce structured outputs for later steps.\n\n### Management and API\n\nWorkflow definitions are drafted, validated, published, enabled, and deleted through the authenticated management UI or these endpoints:\n\n```text\nPOST /api/workflows/validate\nGET /api/workflows\nGET /api/workflows/:id\nPUT /api/workflows/:id\nPOST /api/workflows/:id/publish\nDELETE /api/workflows/:id\n```\n\nOnly published workflows with `enabled: true` are matched for incoming triggers. Saving a workflow creates a new immutable revision. Existing jobs continue to use the revision recorded when they were created.\n\n## Explanation\n\n### Why Workflow YAML Lives in the Database\n\nThe server stores source YAML and normalized revisions in the database. Workers are stateless: they fetch the referenced revision when claiming a job and do not read workflow files from their local filesystem. This keeps every worker on the same definition and makes published revisions reproducible.\n\n### Why Matrix Expansion Happens at Trigger Time\n\nThe stored revision remains one canonical workflow. When a trigger matches, the server creates one queue job per matrix combination. The payload records the matrix context, and the worker resolves that same variant before execution. This avoids storing duplicate workflow revisions while keeping each job independently observable.\n\n### Docker Volumes and Trust\n\n`volumes` and `dockerArgs` are intentionally powerful options. A host volume can expose credentials or signing material, and Docker arguments can change networking, users, capabilities, or isolation. Only trusted administrators should publish workflows using these fields. The runner does not copy, inspect, or redact files mounted into containers.\n\n### Reuse and Imports\n\nFilesystem imports were removed because workers no longer load workflow files. Future step reuse should operate on database-owned definitions or explicit reusable step mechanisms, keeping revisioning and authorization visible to the control plane.\n", zi = "\n <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <circle cx=\"12\" cy=\"12\" r=\"9\"></circle>\n <path d=\"M9.6 9a2.5 2.5 0 1 1 4.2 1.8c-1.2 1-1.8 1.4-1.8 2.7\"></path>\n <path d=\"M12 17h.01\"></path>\n </svg>", Bi = "\n :root { color-scheme: dark; }\n * { box-sizing: border-box; }\n body { margin: 0; background: #030712; color: #e5e7eb; font-family: ui-sans-serif, system-ui, sans-serif; }\n main { max-width: 78rem; margin: 0 auto; padding: 2rem 1rem 5rem; }\n article { max-width: 54rem; margin: 0 auto; line-height: 1.7; }\n h1, h2, h3 { color: #f9fafb; line-height: 1.2; letter-spacing: -0.02em; }\n h1 { font-size: 2.25rem; margin: 0 0 1rem; }\n h2 { border-top: 1px solid #1f2937; margin-top: 3rem; padding-top: 1.5rem; font-size: 1.6rem; }\n h3 { margin-top: 2rem; font-size: 1.15rem; }\n p, li { color: #cbd5e1; }\n a { color: #93c5fd; }\n code { border-radius: .35rem; background: #111827; color: #bfdbfe; padding: .12rem .35rem; font-size: .9em; }\n pre { overflow-x: auto; border: 1px solid #1f2937; border-radius: .75rem; background: #0b1120; padding: 1rem; }\n pre code { background: transparent; padding: 0; color: #d1d5db; }\n table { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; margin: 1rem 0; }\n th, td { border-bottom: 1px solid #1f2937; padding: .65rem .75rem; text-align: left; vertical-align: top; }\n th { color: #f9fafb; background: #111827; }\n blockquote { border-left: 3px solid #6366f1; margin-left: 0; padding-left: 1rem; color: #94a3b8; }\n .topbar { display: flex; justify-content: space-between; gap: 1rem; align-items: center; max-width: 78rem; margin: 0 auto; padding: 1rem; }\n .back { display: inline-flex; align-items: center; gap: .45rem; color: #94a3b8; text-decoration: none; font-size: .85rem; }\n .back:hover, .back:focus-visible { color: #f9fafb; }\n .help-layout { display: grid; grid-template-columns: minmax(0, 54rem); gap: 3rem; justify-content: center; }\n .toc { display: none; }\n .toc nav { position: sticky; top: 1.25rem; max-height: calc(100vh - 2.5rem); overflow-y: auto; border-left: 1px solid #1f2937; padding-left: 1rem; }\n .toc-title { margin: 0 0 .65rem; color: #f9fafb; font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }\n .toc ul { display: grid; gap: .15rem; list-style: none; margin: 0; padding: 0; }\n .toc a { display: block; border-radius: .35rem; color: #94a3b8; font-size: .8rem; line-height: 1.35; padding: .3rem .45rem; text-decoration: none; }\n .toc a:hover, .toc a:focus-visible { background: #111827; color: #dbeafe; outline: none; }\n .toc-depth-2 { padding-left: .9rem !important; }\n .toc-depth-3 { padding-left: 1.8rem !important; font-size: .75rem !important; }\n @media (min-width: 1024px) { .help-layout { grid-template-columns: minmax(0, 54rem) 16rem; justify-content: center; } .toc { display: block; } }\n @media (min-width: 640px) { main { padding: 3rem 1.5rem 6rem; } }\n";
5555
5574
  function Vi(e = !1) {
5556
5575
  let t = /* @__PURE__ */ new Map(), n = (e) => {
5557
5576
  let n = e.replace(/[`*_~]/g, "").replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section", r = t.get(n) || 0;
@@ -6109,7 +6128,7 @@ var Hi = {
6109
6128
  }
6110
6129
  }
6111
6130
  }
6112
- }, Ui = "<!doctype html>\n<html lang=\"en\" class=\"dark\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <meta name=\"theme-color\" content=\"#030712\" />\n <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n <link rel=\"icon\" href=\"/app-icon.svg\" type=\"image/svg+xml\" />\n <title>Runner Workflow Management</title>\n <script type=\"importmap\">\n { \"imports\": { \"@li3/\": \"https://cdn.li3.dev/@li3/\" } }\n <\/script>\n <style>\n body[data-page='list'] .editor-panel {\n display: none;\n }\n body[data-page='editor'] .workflow-list,\n body[data-page='editor'] .secrets-panel {\n display: none;\n }\n </style>\n </head>\n <body data-page=\"__WORKFLOW_PAGE__\" class=\"min-h-screen bg-gray-950 p-4 font-sans text-gray-100 md:p-6\">\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-editor.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-block.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n\n <template app>\n <main class=\"mx-auto max-w-6xl space-y-5\">\n <header class=\"flex flex-col gap-4 border-b border-gray-800 pb-5 sm:flex-row sm:items-end sm:justify-between\">\n <div class=\"flex items-start gap-3\">\n <a\n href=\"/runs\"\n aria-label=\"Back to dashboard\"\n class=\"mt-1 rounded-lg border border-gray-800 bg-gray-900 p-2 text-gray-400 transition hover:border-indigo-500/60 hover:text-indigo-300\"\n >\n <lucide-icon icon=\"arrow-left\" size=\"17\"></lucide-icon>\n </a>\n <div>\n <p class=\"text-xs font-semibold uppercase tracking-[0.18em] text-indigo-400\">Runner engine</p>\n <h1 class=\"mt-1 text-2xl font-bold tracking-tight text-white\">Workflow control room</h1>\n <p class=\"mt-1 text-sm text-gray-400\">Draft, validate, and publish revisioned YAML definitions.</p>\n </div>\n </div>\n <a\n href=\"/workflows/new\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-950/40 transition hover:bg-indigo-400\"\n >\n <lucide-icon icon=\"plus\" size=\"16\"></lucide-icon>\n New workflow\n </a>\n <a\n href=\"/help\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-gray-800 bg-gray-900 px-3 py-2.5 text-sm font-medium text-gray-300 outline-none transition hover:border-indigo-500/50 hover:text-indigo-200 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 24 24\"\n width=\"16\"\n height=\"16\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.8\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\"></circle>\n <path d=\"M9.6 9a2.5 2.5 0 1 1 4.2 1.8c-1.2 1-1.8 1.4-1.8 2.7\"></path>\n <path d=\"M12 17h.01\"></path>\n </svg>\n Help\n </a>\n </header>\n\n <template if=\"notice\">\n <div\n role=\"status\"\n class=\"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm\"\n bind-class=\"noticeError ? 'border-rose-500/40 bg-rose-500/10 text-rose-100' : 'border-emerald-500/40 bg-emerald-500/10 text-emerald-100'\"\n >\n <lucide-icon bind-icon=\"noticeError ? 'circle-alert' : 'circle-check'\" size=\"17\"></lucide-icon>\n <span>{{ notice }}</span>\n </div>\n </template>\n\n <section id=\"workflow-layout\" class=\"space-y-5\">\n <nav\n class=\"workflow-list rounded-2xl border border-gray-800 bg-gray-900/80 p-4 shadow-xl shadow-black/10\"\n aria-labelledby=\"workflow-list-title\"\n >\n <div class=\"mb-4 flex items-end justify-between gap-3 border-b border-gray-800 pb-4\">\n <div>\n <p class=\"text-[11px] font-semibold uppercase tracking-[0.18em] text-indigo-400\">Definitions</p>\n <h2 id=\"workflow-list-title\" class=\"mt-1 text-lg font-semibold text-white\">Workflows</h2>\n </div>\n <span\n class=\"rounded-full border border-gray-700 bg-gray-950 px-2.5 py-1 text-xs font-mono text-gray-400\"\n aria-label=\"Workflow count\"\n >\n {{ workflows.length }}\n </span>\n </div>\n <ul class=\"grid gap-2\" aria-label=\"Workflow definitions\">\n <template for=\"workflow of workflows\">\n <li>\n <a\n attr-href=\"'/workflows/' + workflow.id\"\n class=\"group flex min-h-16 items-center gap-3 rounded-xl border border-gray-800 bg-gray-950/50 px-3.5 py-3 text-left text-sm text-gray-300 outline-none transition hover:border-indigo-500/50 hover:bg-indigo-500/10 focus-visible:border-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/50\"\n >\n <span\n class=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition group-hover:bg-indigo-500/15 group-hover:text-indigo-300\"\n >\n <lucide-icon icon=\"workflow\" size=\"17\"></lucide-icon>\n </span>\n <span class=\"min-w-0 flex-1\">\n <span class=\"block truncate font-medium text-gray-100\">{{ workflow.name }}</span>\n <span class=\"mt-0.5 block truncate font-mono text-[11px] text-gray-500\">{{ workflow.id }}</span>\n </span>\n <span\n class=\"rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-wide\"\n bind-class=\"workflow.status === 'published' ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300' : workflow.status === 'archived' ? 'border-gray-700 bg-gray-800 text-gray-500' : 'border-amber-500/30 bg-amber-500/10 text-amber-300'\"\n >{{ workflow.status }}</span\n >\n <lucide-icon\n icon=\"arrow-up-right\"\n size=\"16\"\n class=\"shrink-0 text-gray-600 transition group-hover:text-indigo-300\"\n ></lucide-icon>\n </a>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-gray-700 px-4 py-8 text-center\"\n class-hidden=\"workflows.length > 0\"\n >\n <lucide-icon icon=\"workflow\" size=\"22\" class=\"mx-auto text-gray-600\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-gray-300\">No workflows yet</p>\n <p class=\"mt-1 text-xs text-gray-500\">Create a definition to start automating jobs.</p>\n </div>\n </nav>\n\n <section\n class=\"editor-panel overflow-hidden rounded-xl border border-gray-800 bg-gray-900/80 shadow-xl shadow-black/10\"\n >\n <div class=\"border-b border-gray-800 px-4 pt-3 sm:px-5\">\n <a\n href=\"/workflows\"\n class=\"inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-gray-400 outline-none transition hover:bg-gray-800 hover:text-gray-100 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <lucide-icon icon=\"arrow-left\" size=\"14\"></lucide-icon>\n Back to workflows\n </a>\n </div>\n <div class=\"border-b border-gray-800 bg-gray-900 px-4 py-4 sm:px-5\">\n <div class=\"flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between\">\n <label class=\"block max-w-xl flex-1 text-xs font-semibold uppercase tracking-wider text-gray-400\"\n >Workflow ID\n <input\n bind-value=\"workflowId\"\n on-input=\"setWorkflowId($event)\"\n bind-disabled=\"selectedId\"\n aria-describedby=\"workflow-id-help\"\n class=\"mt-2 w-full rounded-lg border border-gray-700 bg-gray-950 px-3 py-2.5 font-mono text-sm normal-case tracking-normal text-white outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20\"\n placeholder=\"derived-from-workflow-name\"\n />\n <span\n id=\"workflow-id-help\"\n class=\"mt-1 block text-[11px] font-normal normal-case tracking-normal text-gray-500\"\n >The ID becomes permanent after the first save.</span\n >\n </label>\n <label class=\"inline-flex items-center gap-2 text-sm text-gray-300\">\n <input\n type=\"checkbox\"\n bind-checked=\"enabled\"\n on-change=\"setEnabled($event)\"\n class=\"h-4 w-4 rounded border-gray-700 bg-gray-950 text-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n />\n Enabled\n </label>\n <div class=\"flex flex-wrap gap-2\" class-hidden=\"readOnly\">\n <button\n type=\"button\"\n aria-label=\"Save workflow draft\"\n title=\"Save workflow draft\"\n on-click=\"save()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center gap-2 rounded-lg bg-indigo-500 px-3 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-400 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"save\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Save draft</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Publish workflow\"\n title=\"Publish workflow\"\n on-click=\"publish()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center gap-2 rounded-lg border border-emerald-500/50 px-3 py-2.5 text-sm font-medium text-emerald-300 transition hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"rocket\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Publish</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Delete workflow\"\n title=\"Delete workflow\"\n on-click=\"remove()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center gap-2 rounded-lg border border-rose-500/50 px-3 py-2.5 text-sm font-medium text-rose-300 transition hover:bg-rose-500/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"trash\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Delete</span>\n </button>\n </div>\n </div>\n </div>\n <div class=\"p-3 sm:p-5\">\n <div class=\"mb-2 flex items-center justify-between px-1 text-xs text-gray-500\">\n <div class=\"flex items-center gap-2\">\n <button\n type=\"button\"\n aria-label=\"Validate workflow\"\n title=\"Validate workflow\"\n on-click=\"validate(1)\"\n bind-disabled=\"validation.running\"\n bind-class=\"{ 'hidden': readOnly, 'text-emerald-400': validation.valid, 'text-rose-400': !validation.valid }\"\n class=\"disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon\n class=\"inline-block\"\n bind-icon=\"validation.running ? 'loader-circle' : (validation.valid ? 'circle-check' : 'circle-alert')\"\n bind-class=\"validation.running ? 'animate-spin' : ''\"\n size=\"16\"\n ></lucide-icon>\n </button>\n <span class=\"font-medium\" bind-class=\"validation.error ? 'bg-rose-500/10 text-rose-100' : ''\"\n >{{ validation.error || '' }}</span\n >\n </div>\n <span class=\"font-mono\">{{ source.length }} characters</span>\n </div>\n <template if=\"readOnly\">\n <code-block\n language=\"yaml\"\n theme=\"atom-one-dark\"\n class=\"block h-[calc(100vh-23rem)] min-h-[28rem] overflow-auto rounded-lg border border-gray-700 bg-gray-950 p-4\"\n bind-source=\"source\"\n ></code-block>\n </template>\n <template if=\"!readOnly\">\n <code-editor\n id=\"source-yaml\"\n language=\"yaml\"\n theme=\"atom-one-dark\"\n nostatus=\"true\"\n class=\"block h-[calc(100vh-23rem)] min-h-[28rem] overflow-hidden rounded-lg border border-gray-700 bg-gray-950\"\n bind-value=\"source\"\n on-change=\"setSource($event.detail)\"\n ></code-editor>\n </template>\n <details\n class=\"mt-4 overflow-hidden rounded-xl border border-gray-800 bg-gray-950/50\"\n on-toggle=\"loadHelp($event)\"\n >\n <summary\n class=\"cursor-pointer list-none px-4 py-3 text-sm font-medium text-gray-300 outline-none transition hover:bg-gray-900 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <span class=\"inline-flex items-center gap-2\"\n ><lucide-icon icon=\"circle-question-mark\" size=\"16\"></lucide-icon>Syntax</span\n >\n <span class=\"ml-2 text-xs font-normal text-gray-500\">Reference, tutorials, and examples</span>\n </summary>\n <div\n ref=\"helpContent\"\n class=\"max-h-[38rem] overflow-y-auto border-t border-gray-800 bg-gray-950 px-4 py-5\"\n role=\"region\"\n aria-label=\"Workflow syntax help\"\n aria-live=\"polite\"\n >\n <p class=\"text-sm text-gray-500\">Expand to load the workflow syntax reference.</p>\n </div>\n </details>\n </div>\n </section>\n </section>\n\n <section\n class=\"secrets-panel rounded-2xl border border-gray-800 bg-gray-900/80 p-4 shadow-xl shadow-black/10 sm:p-5\"\n aria-labelledby=\"secrets-title\"\n >\n <div class=\"mb-5 flex items-start justify-between gap-3\">\n <div class=\"flex items-start gap-3\">\n <span class=\"rounded-lg bg-indigo-500/10 p-2 text-indigo-300\"\n ><lucide-icon icon=\"key-round\" size=\"18\"></lucide-icon\n ></span>\n <div>\n <h2 id=\"secrets-title\" class=\"text-lg font-semibold text-white\">Secrets</h2>\n <p class=\"mt-0.5 text-sm text-gray-400\">Write-only credentials available to workflows.</p>\n </div>\n </div>\n <span\n class=\"rounded-full border border-gray-700 bg-gray-950 px-2.5 py-1 text-xs font-mono text-gray-400\"\n aria-label=\"Secret count\"\n >{{ secrets.length }}</span\n >\n </div>\n <div class=\"mb-5 rounded-xl border border-gray-800 bg-gray-950/50 p-3\">\n <p class=\"mb-3 text-xs text-gray-500\">Saving an existing name replaces its encrypted value.</p>\n <div class=\"grid gap-3 md:grid-cols-[14rem_1fr_auto_auto]\">\n <label class=\"sr-only\" for=\"secret-name\">Secret name</label>\n <input\n id=\"secret-name\"\n bind-value=\"secretName\"\n on-input=\"setSecretName($event)\"\n class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 font-mono text-sm uppercase outline-none transition placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n placeholder=\"NPM_TOKEN\"\n />\n <label class=\"sr-only\" for=\"secret-value\">Secret value</label>\n <input\n id=\"secret-value\"\n ref=\"secretValueInput\"\n bind-value=\"secretValue\"\n on-input=\"setSecretValue($event)\"\n type=\"password\"\n class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 font-mono text-sm outline-none transition placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n placeholder=\"New secret value\"\n />\n <button\n type=\"button\"\n aria-label=\"Save secret value\"\n title=\"Save secret value\"\n on-click=\"saveSecret()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-indigo-500/60 px-3 py-2.5 text-sm font-medium text-indigo-200 outline-none transition hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-50\"\n >\n <lucide-icon icon=\"lock-keyhole\" size=\"16\"></lucide-icon><span class=\"hidden sm:inline\">Save</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Delete secret\"\n title=\"Delete secret\"\n on-click=\"removeSecret()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-rose-500/50 px-3 py-2.5 text-sm font-medium text-rose-300 outline-none transition hover:bg-rose-500/10 focus-visible:ring-2 focus-visible:ring-rose-400/60 disabled:opacity-50\"\n >\n <lucide-icon icon=\"trash\" size=\"16\"></lucide-icon><span class=\"hidden sm:inline\">Delete</span>\n </button>\n </div>\n </div>\n <ul class=\"grid gap-2\" aria-label=\"Saved secrets\">\n <template for=\"name of secrets\">\n <li>\n <button\n type=\"button\"\n on-click=\"selectSecret(name)\"\n class=\"group flex min-h-14 w-full items-center gap-3 rounded-xl border border-gray-800 bg-gray-950/50 px-3.5 py-3 text-left outline-none transition hover:border-indigo-500/50 hover:bg-indigo-500/10 focus-visible:border-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/50\"\n aria-label=\"Edit secret {{ name }}\"\n >\n <span\n class=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-800 text-gray-500 group-hover:text-indigo-300\"\n ><lucide-icon icon=\"key-round\" size=\"15\"></lucide-icon\n ></span>\n <span class=\"min-w-0 flex-1 truncate font-mono text-sm text-gray-200\">{{ name }}</span>\n <span class=\"text-xs text-gray-500\">Select</span>\n <lucide-icon\n icon=\"chevron-right\"\n size=\"16\"\n class=\"text-gray-600 transition group-hover:text-indigo-300\"\n ></lucide-icon>\n </button>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-gray-700 px-4 py-8 text-center\"\n class-hidden=\"secrets.length > 0\"\n >\n <lucide-icon icon=\"key-round\" size=\"22\" class=\"mx-auto text-gray-600\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-gray-300\">No secrets configured</p>\n <p class=\"mt-1 text-xs text-gray-500\">Add one above to make it available to a workflow.</p>\n </div>\n </section>\n </main>\n\n <script setup>\n import { onDestroy, onInit, ref, templateRef } from '@li3/web';\n\n export default function () {\n const page = document.body.dataset.page;\n const initialId = __WORKFLOW_ID__;\n const initialRevision = __WORKFLOW_REVISION__;\n const readOnly = Boolean(initialRevision);\n const workflows = ref([]);\n const secrets = ref([]);\n const source = ref('');\n const validation = ref({ valid: true });\n const savedSource = ref('');\n const workflowId = ref('');\n const enabled = ref(true);\n const savedEnabled = ref(true);\n const selectedId = ref('');\n const secretName = ref('');\n const secretValue = ref('');\n const notice = ref('');\n const noticeError = ref(false);\n const busy = ref(false);\n const secretValueInput = templateRef('secretValueInput');\n const helpContent = templateRef('helpContent');\n const helpLoaded = ref(false);\n const helpLoading = ref(false);\n let validationTimer;\n\n const showNotice = (message, error = false) => {\n notice.value = message;\n noticeError.value = error;\n };\n\n const api = async (url, options = {}) => {\n const response = await fetch(url, {\n headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) },\n ...options,\n });\n const body = response.status === 204 ? null : await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`);\n return body;\n };\n\n const derivedId = () =>\n source.value\n .match(/^\\s*(?:id:\\s*([^\\n]+)|name:\\s*([^\\n]+))/m)\n ?.slice(1)\n .find(Boolean)\n ?.trim()\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '-')\n .replace(/(^-|-$)/g, '') || '';\n\n const run = async (action) => {\n if (busy.value) return;\n\n busy.value = true;\n\n try {\n await action();\n } catch (error) {\n showNotice(error.message, true);\n } finally {\n busy.value = false;\n }\n };\n\n const loadWorkflows = async () => {\n workflows.value = (await api('/api/workflows')).workflows;\n };\n\n const loadSecrets = async () => {\n secrets.value = (await api('/api/secrets')).secrets;\n };\n\n const openWorkflow = async (id) => {\n const workflowUrl = initialRevision\n ? `/api/workflows/${id}?revision=${initialRevision}`\n : `/api/workflows/${id}`;\n const workflow = await api(workflowUrl);\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n source.value = workflow.sourceYaml;\n savedSource.value = workflow.sourceYaml;\n enabled.value = workflow.enabled;\n savedEnabled.value = workflow.enabled;\n };\n\n const validateSource = async () => {\n validation.value = await api('/api/workflows/validate', {\n method: 'POST',\n body: JSON.stringify({ sourceYaml: source.value }),\n });\n };\n\n const validate = (ms = 1000) => {\n if (page !== 'editor') return;\n\n clearTimeout(validationTimer);\n validationTimer = setTimeout(() => {\n validation.running = true;\n validateSource();\n }, ms);\n };\n\n const save = () =>\n run(async () => {\n const id = workflowId.value.trim() || derivedId();\n if (!id) throw new Error('Set an ID or add a workflow name first.');\n if (\n initialId &&\n id === selectedId.value &&\n source.value === savedSource.value &&\n enabled.value === savedEnabled.value\n ) {\n showNotice('No changes to save.');\n return;\n }\n const workflow = await api(`/api/workflows/${id}`, {\n method: 'PUT',\n body: JSON.stringify({ sourceYaml: source.value, enabled: enabled.value }),\n });\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n savedSource.value = source.value;\n savedEnabled.value = enabled.value;\n showNotice(`Saved ${workflow.id} as draft revision ${workflow.revision}.`);\n if (!initialId) history.replaceState(null, '', `/workflows/${workflow.id}`);\n });\n const publish = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id) throw new Error('Save a workflow before publishing it.');\n const workflow = await api(`/api/workflows/${id}/publish`, { method: 'POST' });\n selectedId.value = workflow.id;\n showNotice(`Published ${workflow.id} revision ${workflow.revision}.`);\n });\n const remove = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id || !confirm(`Delete ${id} and all of its revisions?`)) return;\n await api(`/api/workflows/${id}`, { method: 'DELETE' });\n window.location.href = '/workflows';\n });\n const saveSecret = () =>\n run(async () => {\n const name = secretName.value.trim();\n if (!name || !secretValue.value) throw new Error('Set a secret name and value first.');\n await api(`/api/secrets/${name}`, { method: 'PUT', body: JSON.stringify({ value: secretValue.value }) });\n secretValue.value = '';\n await loadSecrets();\n showNotice(`Saved ${name}.`);\n });\n const removeSecret = () =>\n run(async () => {\n const name = secretName.value.trim();\n if (!name || !confirm(`Delete ${name}?`)) return;\n await api(`/api/secrets/${name}`, { method: 'DELETE' });\n secretName.value = '';\n secretValue.value = '';\n await loadSecrets();\n showNotice(`Deleted ${name}.`);\n });\n const setSource = (value) => {\n source.value = value;\n validate();\n };\n const setWorkflowId = (event) => {\n workflowId.value = event.target.value;\n };\n const setEnabled = (event) => {\n enabled.value = event.target.checked;\n };\n const setSecretName = (event) => {\n secretName.value = event.target.value;\n };\n const setSecretValue = (event) => {\n secretValue.value = event.target.value;\n };\n const selectSecret = (name) => {\n secretName.value = name;\n secretValueInput.value?.focus();\n };\n const visitWorkflow = (id) => {\n window.location.href = `/workflows/${id}`;\n };\n const loadHelp = async (event) => {\n if (!event.target.open || helpLoaded.value || helpLoading.value || !helpContent.value) return;\n helpLoading.value = true;\n helpContent.value.textContent = 'Loading workflow syntax help...';\n try {\n const response = await fetch('/help?embed=1', { headers: { accept: 'text/html' } });\n if (!response.ok) throw new Error(`Help request failed: ${response.status}`);\n const html = await response.text();\n const parsed = new DOMParser().parseFromString(html, 'text/html');\n const sourceNodes = [\n ...Array.from(parsed.head.querySelectorAll('style')),\n ...Array.from(parsed.body.childNodes),\n ];\n const nodes = sourceNodes.map((node) => document.importNode(node, true));\n helpContent.value.replaceChildren(...nodes);\n helpLoaded.value = true;\n } catch (error) {\n helpContent.value.textContent = 'Unable to load workflow syntax help.';\n console.error(error);\n } finally {\n helpLoading.value = false;\n }\n };\n\n onDestroy(() => clearTimeout(validationTimer));\n\n onInit(() => {\n const loadPage =\n page === 'editor'\n ? initialId\n ? openWorkflow(initialId)\n : Promise.resolve()\n : Promise.all([loadWorkflows(), loadSecrets()]);\n loadPage.catch((error) => showNotice(error.message, true));\n });\n\n return {\n workflows,\n secrets,\n source,\n validation,\n workflowId,\n enabled,\n readOnly,\n selectedId,\n secretName,\n secretValue,\n notice,\n noticeError,\n busy,\n validate,\n save,\n publish,\n remove,\n saveSecret,\n removeSecret,\n setSource,\n setWorkflowId,\n setEnabled,\n setSecretName,\n setSecretValue,\n selectSecret,\n visitWorkflow,\n loadHelp,\n };\n }\n <\/script>\n </template>\n\n <script type=\"module\">\n import '@li3/web';\n <\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n";
6131
+ }, Ui = "<!doctype html>\n<html lang=\"en\" class=\"dark\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <meta name=\"theme-color\" content=\"#030712\" />\n <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n <link rel=\"icon\" href=\"/app-icon.svg\" type=\"image/svg+xml\" />\n <title>Runner Workflow Management</title>\n <script type=\"importmap\">\n { \"imports\": { \"@li3/\": \"https://cdn.li3.dev/@li3/\" } }\n <\/script>\n <style>\n body[data-page='list'] .editor-panel {\n display: none;\n }\n body[data-page='editor'] .workflow-list,\n body[data-page='editor'] .secrets-panel {\n display: none;\n }\n </style>\n </head>\n <body data-page=\"__WORKFLOW_PAGE__\" class=\"min-h-screen bg-gray-950 p-4 font-sans text-gray-100 md:p-6\">\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-editor.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-block.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n\n <template app>\n <main class=\"mx-auto max-w-6xl space-y-5\">\n <header class=\"flex flex-col gap-4 border-b border-gray-800 pb-5 sm:flex-row sm:items-end sm:justify-between\">\n <div class=\"flex items-start gap-3\">\n <a\n href=\"/runs\"\n aria-label=\"Back to dashboard\"\n class=\"mt-1 rounded-lg border border-gray-800 bg-gray-900 p-2 text-gray-400 transition hover:border-indigo-500/60 hover:text-indigo-300\"\n >\n <lucide-icon icon=\"arrow-left\" size=\"17\"></lucide-icon>\n </a>\n <div>\n <p class=\"text-xs font-semibold uppercase tracking-[0.18em] text-indigo-400\">Runner engine</p>\n <h1 class=\"mt-1 text-2xl font-bold tracking-tight text-white\">Workflow control room</h1>\n <p class=\"mt-1 text-sm text-gray-400\">Draft, validate, and publish revisioned YAML definitions.</p>\n </div>\n </div>\n <a\n href=\"/help\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-gray-800 bg-gray-900 px-3 py-2.5 text-sm font-medium text-gray-300 outline-none transition hover:border-indigo-500/50 hover:text-indigo-200 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 24 24\"\n width=\"16\"\n height=\"16\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.8\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\"></circle>\n <path d=\"M9.6 9a2.5 2.5 0 1 1 4.2 1.8c-1.2 1-1.8 1.4-1.8 2.7\"></path>\n <path d=\"M12 17h.01\"></path>\n </svg>\n Help\n </a>\n </header>\n\n <template if=\"notice\">\n <div\n role=\"status\"\n class=\"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm\"\n bind-class=\"noticeError ? 'border-rose-500/40 bg-rose-500/10 text-rose-100' : 'border-emerald-500/40 bg-emerald-500/10 text-emerald-100'\"\n >\n <lucide-icon bind-icon=\"noticeError ? 'circle-alert' : 'circle-check'\" size=\"17\"></lucide-icon>\n <span>{{ notice }}</span>\n </div>\n </template>\n\n <section id=\"workflow-layout\" class=\"space-y-5\">\n <nav\n class=\"workflow-list rounded-2xl border border-gray-800 bg-gray-900/80 p-4 shadow-xl shadow-black/10\"\n aria-labelledby=\"workflow-list-title\"\n >\n <div class=\"mb-4 flex items-end justify-between gap-3 border-b border-gray-800 pb-4\">\n <div>\n <p class=\"text-[11px] font-semibold uppercase tracking-[0.18em] text-indigo-400\">Definitions</p>\n <h2 id=\"workflow-list-title\" class=\"mt-1 text-lg font-semibold text-white\">Workflows</h2>\n </div>\n <a\n href=\"/workflows/new\"\n class=\"inline-flex items-center gap-2 rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white outline-none transition hover:bg-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <lucide-icon icon=\"plus\" size=\"16\"></lucide-icon>\n New workflow\n </a>\n </div>\n <ul class=\"grid gap-2\" aria-label=\"Workflow definitions\">\n <template for=\"workflow of workflows\">\n <li>\n <a\n attr-href=\"'/workflows/' + workflow.id\"\n class=\"group flex min-h-16 items-center gap-3 rounded-xl border border-gray-800 bg-gray-950/50 px-3.5 py-3 text-left text-sm text-gray-300 outline-none transition hover:border-indigo-500/50 hover:bg-indigo-500/10 focus-visible:border-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/50\"\n >\n <span\n class=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition group-hover:bg-indigo-500/15 group-hover:text-indigo-300\"\n >\n <lucide-icon icon=\"workflow\" size=\"17\"></lucide-icon>\n </span>\n <span class=\"min-w-0 flex-1\">\n <span class=\"block truncate text-base font-semibold text-gray-100\">{{ workflow.name }}</span>\n <span class=\"mt-0.5 block truncate font-mono text-[11px] text-gray-500\">{{ workflow.id }}</span>\n </span>\n <span\n class=\"rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-wide\"\n bind-class=\"workflow.status === 'published' ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300' : workflow.status === 'archived' ? 'border-gray-700 bg-gray-800 text-gray-500' : 'border-amber-500/30 bg-amber-500/10 text-amber-300'\"\n >{{ workflow.status }}</span\n >\n <lucide-icon\n icon=\"arrow-up-right\"\n size=\"16\"\n class=\"shrink-0 text-gray-600 transition group-hover:text-indigo-300\"\n ></lucide-icon>\n </a>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-gray-700 px-4 py-8 text-center\"\n class-hidden=\"workflows.length > 0\"\n >\n <lucide-icon icon=\"workflow\" size=\"22\" class=\"mx-auto text-gray-600\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-gray-300\">No workflows yet</p>\n <p class=\"mt-1 text-xs text-gray-500\">Create a definition to start automating jobs.</p>\n </div>\n </nav>\n\n <section\n class=\"editor-panel overflow-hidden rounded-xl border border-gray-800 bg-gray-900/80 shadow-xl shadow-black/10\"\n >\n <div class=\"border-b border-gray-800 px-4 pt-3 sm:px-5\">\n <a\n href=\"/workflows\"\n class=\"inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-gray-400 outline-none transition hover:bg-gray-800 hover:text-gray-100 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <lucide-icon icon=\"arrow-left\" size=\"14\"></lucide-icon>\n Back to workflows\n </a>\n </div>\n <div class=\"border-b border-gray-800 bg-gray-900 px-4 py-4 sm:px-5\">\n <div class=\"flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between\">\n <label class=\"block max-w-xl flex-1 text-xs font-semibold uppercase tracking-wider text-gray-400\"\n >Workflow ID\n <input\n bind-value=\"workflowId\"\n on-input=\"setWorkflowId($event)\"\n bind-disabled=\"selectedId\"\n aria-describedby=\"workflow-id-help\"\n class=\"mt-2 w-full rounded-lg border border-gray-700 bg-gray-950 px-3 py-2.5 font-mono text-sm normal-case tracking-normal text-white outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20\"\n placeholder=\"derived-from-workflow-name\"\n />\n <span\n id=\"workflow-id-help\"\n class=\"mt-1 block text-[11px] font-normal normal-case tracking-normal text-gray-500\"\n >The ID becomes permanent after the first save.</span\n >\n </label>\n <label class=\"inline-flex items-center gap-2 text-sm text-gray-300\">\n <input\n type=\"checkbox\"\n bind-checked=\"enabled\"\n on-change=\"setEnabled($event)\"\n class=\"h-4 w-4 rounded border-gray-700 bg-gray-950 text-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n />\n Enabled\n </label>\n <div class=\"flex flex-wrap gap-2\" class-hidden=\"readOnly\">\n <button\n type=\"button\"\n aria-label=\"Save workflow draft\"\n title=\"Save workflow draft\"\n on-click=\"save()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center gap-2 rounded-lg bg-indigo-500 px-3 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-400 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"save\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Save draft</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Publish workflow\"\n title=\"Publish workflow\"\n on-click=\"publish()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center gap-2 rounded-lg border border-emerald-500/50 px-3 py-2.5 text-sm font-medium text-emerald-300 transition hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"rocket\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Publish</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Delete workflow\"\n title=\"Delete workflow\"\n on-click=\"remove()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center gap-2 rounded-lg border border-rose-500/50 px-3 py-2.5 text-sm font-medium text-rose-300 transition hover:bg-rose-500/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"trash\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Delete</span>\n </button>\n </div>\n </div>\n </div>\n <div class=\"p-3 sm:p-5\">\n <div class=\"mb-2 flex items-center justify-between px-1 text-xs text-gray-500\">\n <div class=\"flex items-center gap-2\">\n <button\n type=\"button\"\n aria-label=\"Validate workflow\"\n title=\"Validate workflow\"\n on-click=\"validate(1)\"\n bind-disabled=\"validation.running\"\n bind-class=\"{ 'hidden': readOnly, 'text-emerald-400': validation.valid, 'text-rose-400': !validation.valid }\"\n class=\"disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon\n class=\"inline-block\"\n bind-icon=\"validation.running ? 'loader-circle' : (validation.valid ? 'circle-check' : 'circle-alert')\"\n bind-class=\"validation.running ? 'animate-spin' : ''\"\n size=\"16\"\n ></lucide-icon>\n </button>\n <span class=\"font-medium\" bind-class=\"validation.error ? 'bg-rose-500/10 text-rose-100' : ''\"\n >{{ validation.error || '' }}</span\n >\n </div>\n <span class=\"font-mono\">{{ source.length }} characters</span>\n </div>\n <template if=\"readOnly\">\n <code-block\n language=\"yaml\"\n theme=\"atom-one-dark\"\n class=\"block h-[calc(100vh-23rem)] min-h-[28rem] overflow-auto rounded-lg border border-gray-700 bg-gray-950 p-4\"\n bind-source=\"source\"\n ></code-block>\n </template>\n <template if=\"!readOnly\">\n <code-editor\n id=\"source-yaml\"\n language=\"yaml\"\n theme=\"atom-one-dark\"\n nostatus=\"true\"\n class=\"block h-[calc(100vh-23rem)] min-h-[28rem] overflow-hidden rounded-lg border border-gray-700 bg-gray-950\"\n bind-value=\"source\"\n on-change=\"setSource($event.detail)\"\n ></code-editor>\n </template>\n <details\n class=\"mt-4 overflow-hidden rounded-xl border border-gray-800 bg-gray-950/50\"\n on-toggle=\"loadHelp($event)\"\n >\n <summary\n class=\"cursor-pointer list-none px-4 py-3 text-sm font-medium text-gray-300 outline-none transition hover:bg-gray-900 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <span class=\"inline-flex items-center gap-2\"\n ><lucide-icon icon=\"circle-question-mark\" size=\"16\"></lucide-icon>Syntax</span\n >\n <span class=\"ml-2 text-xs font-normal text-gray-500\">Reference, tutorials, and examples</span>\n </summary>\n <div\n ref=\"helpContent\"\n class=\"max-h-[38rem] overflow-y-auto border-t border-gray-800 bg-gray-950 px-4 py-5\"\n role=\"region\"\n aria-label=\"Workflow syntax help\"\n aria-live=\"polite\"\n >\n <p class=\"text-sm text-gray-500\">Expand to load the workflow syntax reference.</p>\n </div>\n </details>\n </div>\n </section>\n </section>\n\n <section\n class=\"secrets-panel rounded-2xl border border-gray-800 bg-gray-900/80 p-4 shadow-xl shadow-black/10 sm:p-5\"\n aria-labelledby=\"secrets-title\"\n >\n <div class=\"mb-5 flex items-start justify-between gap-3\">\n <div class=\"flex items-start gap-3\">\n <span class=\"rounded-lg bg-indigo-500/10 p-2 text-indigo-300\"\n ><lucide-icon icon=\"key-round\" size=\"18\"></lucide-icon\n ></span>\n <div>\n <h2 id=\"secrets-title\" class=\"text-lg font-semibold text-white\">Secrets</h2>\n <p class=\"mt-0.5 text-sm text-gray-400\">Write-only credentials available to workflows.</p>\n </div>\n </div>\n <span\n class=\"rounded-full border border-gray-700 bg-gray-950 px-2.5 py-1 text-xs font-mono text-gray-400\"\n aria-label=\"Secret count\"\n >{{ secrets.length }}</span\n >\n </div>\n <div class=\"mb-5 rounded-xl border border-gray-800 bg-gray-950/50 p-3\">\n <p class=\"mb-3 text-xs text-gray-500\">Saving an existing name replaces its encrypted value.</p>\n <div class=\"grid gap-3 md:grid-cols-[14rem_1fr_auto_auto]\">\n <label class=\"sr-only\" for=\"secret-name\">Secret name</label>\n <input\n id=\"secret-name\"\n bind-value=\"secretName\"\n on-input=\"setSecretName($event)\"\n class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 font-mono text-sm uppercase outline-none transition placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n placeholder=\"NPM_TOKEN\"\n />\n <label class=\"sr-only\" for=\"secret-value\">Secret value</label>\n <input\n id=\"secret-value\"\n ref=\"secretValueInput\"\n bind-value=\"secretValue\"\n on-input=\"setSecretValue($event)\"\n type=\"password\"\n class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 font-mono text-sm outline-none transition placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n placeholder=\"New secret value\"\n />\n <label class=\"sr-only\" for=\"secret-file\">Secret file</label>\n <input\n id=\"secret-file\"\n ref=\"secretFileInput\"\n on-change=\"setSecretFile($event)\"\n type=\"file\"\n class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-xs text-gray-400 file:mr-3 file:rounded file:border-0 file:bg-gray-700 file:px-2 file:py-1 file:text-xs file:text-gray-200 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30\"\n />\n <button\n type=\"button\"\n aria-label=\"Save secret value\"\n title=\"Save secret value\"\n on-click=\"saveSecret()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-indigo-500/60 px-3 py-2.5 text-sm font-medium text-indigo-200 outline-none transition hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-50\"\n >\n <lucide-icon icon=\"lock-keyhole\" size=\"16\"></lucide-icon><span class=\"hidden sm:inline\">Save</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Delete secret\"\n title=\"Delete secret\"\n on-click=\"removeSecret()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-rose-500/50 px-3 py-2.5 text-sm font-medium text-rose-300 outline-none transition hover:bg-rose-500/10 focus-visible:ring-2 focus-visible:ring-rose-400/60 disabled:opacity-50\"\n >\n <lucide-icon icon=\"trash\" size=\"16\"></lucide-icon><span class=\"hidden sm:inline\">Delete</span>\n </button>\n </div>\n </div>\n <ul class=\"grid gap-2\" aria-label=\"Saved secrets\">\n <template for=\"name of secrets\">\n <li>\n <button\n type=\"button\"\n on-click=\"selectSecret(name)\"\n class=\"group flex min-h-14 w-full items-center gap-3 rounded-xl border border-gray-800 bg-gray-950/50 px-3.5 py-3 text-left outline-none transition hover:border-indigo-500/50 hover:bg-indigo-500/10 focus-visible:border-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/50\"\n aria-label=\"Edit secret {{ name }}\"\n >\n <span\n class=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-800 text-gray-500 group-hover:text-indigo-300\"\n ><lucide-icon icon=\"key-round\" size=\"15\"></lucide-icon\n ></span>\n <span class=\"min-w-0 flex-1 truncate font-mono text-sm text-gray-200\">{{ name }}</span>\n <span class=\"text-xs text-gray-500\">Select</span>\n <lucide-icon\n icon=\"chevron-right\"\n size=\"16\"\n class=\"text-gray-600 transition group-hover:text-indigo-300\"\n ></lucide-icon>\n </button>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-gray-700 px-4 py-8 text-center\"\n class-hidden=\"secrets.length > 0\"\n >\n <lucide-icon icon=\"key-round\" size=\"22\" class=\"mx-auto text-gray-600\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-gray-300\">No secrets configured</p>\n <p class=\"mt-1 text-xs text-gray-500\">Add one above to make it available to a workflow.</p>\n </div>\n </section>\n </main>\n\n <script setup>\n import { onDestroy, onInit, ref, templateRef } from '@li3/web';\n\n export default function () {\n const page = document.body.dataset.page;\n const initialId = __WORKFLOW_ID__;\n const initialRevision = __WORKFLOW_REVISION__;\n const readOnly = Boolean(initialRevision);\n const workflows = ref([]);\n const secrets = ref([]);\n const source = ref('');\n const validation = ref({ valid: true });\n const savedSource = ref('');\n const workflowId = ref('');\n const enabled = ref(true);\n const savedEnabled = ref(true);\n const selectedId = ref('');\n const secretName = ref('');\n const secretValue = ref('');\n const secretFileName = ref('');\n const secretFileData = ref('');\n const notice = ref('');\n const noticeError = ref(false);\n const busy = ref(false);\n const secretValueInput = templateRef('secretValueInput');\n const secretFileInput = templateRef('secretFileInput');\n const helpContent = templateRef('helpContent');\n const helpLoaded = ref(false);\n const helpLoading = ref(false);\n let validationTimer;\n\n const showNotice = (message, error = false) => {\n notice.value = message;\n noticeError.value = error;\n };\n\n const api = async (url, options = {}) => {\n const response = await fetch(url, {\n headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) },\n ...options,\n });\n const body = response.status === 204 ? null : await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`);\n return body;\n };\n\n const derivedId = () =>\n source.value\n .match(/^\\s*(?:id:\\s*([^\\n]+)|name:\\s*([^\\n]+))/m)\n ?.slice(1)\n .find(Boolean)\n ?.trim()\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '-')\n .replace(/(^-|-$)/g, '') || '';\n\n const run = async (action) => {\n if (busy.value) return;\n\n busy.value = true;\n\n try {\n await action();\n } catch (error) {\n showNotice(error.message, true);\n } finally {\n busy.value = false;\n }\n };\n\n const loadWorkflows = async () => {\n workflows.value = (await api('/api/workflows')).workflows;\n };\n\n const loadSecrets = async () => {\n secrets.value = (await api('/api/secrets')).secrets;\n };\n\n const openWorkflow = async (id) => {\n const workflowUrl = initialRevision\n ? `/api/workflows/${id}?revision=${initialRevision}`\n : `/api/workflows/${id}`;\n const workflow = await api(workflowUrl);\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n source.value = workflow.sourceYaml;\n savedSource.value = workflow.sourceYaml;\n enabled.value = workflow.enabled;\n savedEnabled.value = workflow.enabled;\n };\n\n const validateSource = async () => {\n return api('/api/workflows/validate', {\n method: 'POST',\n body: JSON.stringify({ sourceYaml: source.value }),\n });\n };\n\n const validate = (ms = 1000) => {\n if (page !== 'editor') return;\n\n clearTimeout(validationTimer);\n validationTimer = setTimeout(() => {\n validation.value = { ...validation.value, running: true, error: '' };\n void validateSource()\n .then((result) => {\n validation.value = { ...result, running: false };\n })\n .catch((error) => {\n validation.value = { valid: false, running: false, error: error.message };\n showNotice(error.message, true);\n });\n }, ms);\n };\n\n const save = () =>\n run(async () => {\n const id = workflowId.value.trim() || derivedId();\n if (!id) throw new Error('Set an ID or add a workflow name first.');\n if (\n initialId &&\n id === selectedId.value &&\n source.value === savedSource.value &&\n enabled.value === savedEnabled.value\n ) {\n showNotice('No changes to save.');\n return;\n }\n const workflow = await api(`/api/workflows/${id}`, {\n method: 'PUT',\n body: JSON.stringify({ sourceYaml: source.value, enabled: enabled.value }),\n });\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n savedSource.value = source.value;\n savedEnabled.value = enabled.value;\n showNotice(`Saved ${workflow.id} as draft revision ${workflow.revision}.`);\n if (!initialId) history.replaceState(null, '', `/workflows/${workflow.id}`);\n });\n const publish = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id) throw new Error('Save a workflow before publishing it.');\n const workflow = await api(`/api/workflows/${id}/publish`, { method: 'POST' });\n selectedId.value = workflow.id;\n showNotice(`Published ${workflow.id} revision ${workflow.revision}.`);\n });\n const remove = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id || !confirm(`Delete ${id} and all of its revisions?`)) return;\n await api(`/api/workflows/${id}`, { method: 'DELETE' });\n window.location.href = '/workflows';\n });\n const saveSecret = () =>\n run(async () => {\n const name = secretName.value.trim();\n if (!name || (!secretValue.value && !secretFileData.value)) throw new Error('Set a secret name and value or choose a file first.');\n await api(`/api/secrets/${name}`, {\n method: 'PUT',\n body: JSON.stringify(secretFileData.value ? { value: secretFileData.value, encoding: 'base64', originalName: secretFileName.value } : { value: secretValue.value, encoding: 'utf8' }),\n });\n secretValue.value = '';\n secretFileName.value = '';\n secretFileData.value = '';\n if (secretFileInput.value) secretFileInput.value.value = '';\n await loadSecrets();\n showNotice(`Saved ${name}.`);\n });\n const removeSecret = () =>\n run(async () => {\n const name = secretName.value.trim();\n if (!name || !confirm(`Delete ${name}?`)) return;\n await api(`/api/secrets/${name}`, { method: 'DELETE' });\n secretName.value = '';\n secretValue.value = '';\n secretFileName.value = '';\n secretFileData.value = '';\n if (secretFileInput.value) secretFileInput.value.value = '';\n await loadSecrets();\n showNotice(`Deleted ${name}.`);\n });\n const setSource = (value) => {\n source.value = value;\n validate();\n };\n const setWorkflowId = (event) => {\n workflowId.value = event.target.value;\n };\n const setEnabled = (event) => {\n enabled.value = event.target.checked;\n };\n const setSecretName = (event) => {\n secretName.value = event.target.value;\n };\n const setSecretValue = (event) => {\n secretValue.value = event.target.value;\n if (secretValue.value) {\n secretFileName.value = '';\n secretFileData.value = '';\n if (secretFileInput.value) secretFileInput.value.value = '';\n }\n };\n const setSecretFile = async (event) => {\n const file = event.target.files?.[0];\n if (!file) return;\n const bytes = new Uint8Array(await file.arrayBuffer());\n let binary = '';\n for (let index = 0; index < bytes.length; index += 0x8000) {\n binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));\n }\n secretFileName.value = file.name;\n secretFileData.value = btoa(binary);\n secretValue.value = '';\n };\n const selectSecret = (name) => {\n secretName.value = name;\n secretValueInput.value?.focus();\n };\n const visitWorkflow = (id) => {\n window.location.href = `/workflows/${id}`;\n };\n const loadHelp = async (event) => {\n if (!event.target.open || helpLoaded.value || helpLoading.value || !helpContent.value) return;\n helpLoading.value = true;\n helpContent.value.textContent = 'Loading workflow syntax help...';\n try {\n const response = await fetch('/help?embed=1', { headers: { accept: 'text/html' } });\n if (!response.ok) throw new Error(`Help request failed: ${response.status}`);\n const html = await response.text();\n const parsed = new DOMParser().parseFromString(html, 'text/html');\n const sourceNodes = [\n ...Array.from(parsed.head.querySelectorAll('style')),\n ...Array.from(parsed.body.childNodes),\n ];\n const nodes = sourceNodes.map((node) => document.importNode(node, true));\n helpContent.value.replaceChildren(...nodes);\n helpLoaded.value = true;\n } catch (error) {\n helpContent.value.textContent = 'Unable to load workflow syntax help.';\n console.error(error);\n } finally {\n helpLoading.value = false;\n }\n };\n\n onDestroy(() => clearTimeout(validationTimer));\n\n onInit(() => {\n const loadPage =\n page === 'editor'\n ? initialId\n ? openWorkflow(initialId)\n : Promise.resolve()\n : Promise.all([loadWorkflows(), loadSecrets()]);\n loadPage.catch((error) => showNotice(error.message, true));\n });\n\n return {\n workflows,\n secrets,\n source,\n validation,\n workflowId,\n enabled,\n readOnly,\n selectedId,\n secretName,\n secretValue,\n secretFileName,\n secretFileData,\n notice,\n noticeError,\n busy,\n validate,\n save,\n publish,\n remove,\n saveSecret,\n removeSecret,\n setSource,\n setWorkflowId,\n setEnabled,\n setSecretName,\n setSecretValue,\n setSecretFile,\n selectSecret,\n visitWorkflow,\n loadHelp,\n };\n }\n <\/script>\n </template>\n\n <script type=\"module\">\n import '@li3/web';\n <\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n";
6113
6132
  //#endregion
6114
6133
  //#region src/workflows-ui.ts
6115
6134
  function Wi(e, t = "", n) {
@@ -10866,7 +10885,7 @@ var Xc = class {
10866
10885
  return;
10867
10886
  }
10868
10887
  try {
10869
- await this.secretRepository.set(n, r.value), t.writeHead(204).end();
10888
+ await this.secretRepository.set(n, r.value, r.encoding || "utf8"), t.writeHead(204).end();
10870
10889
  } catch (e) {
10871
10890
  t.writeHead(422, { "Content-Type": "application/json" }), t.end(JSON.stringify({ error: e.message }));
10872
10891
  }
@@ -10884,7 +10903,7 @@ var Xc = class {
10884
10903
  t.writeHead(200, {
10885
10904
  "Cache-Control": "no-store",
10886
10905
  "Content-Type": "application/json; charset=utf-8"
10887
- }), t.end(JSON.stringify({ secrets: await this.secretRepository.getAll() }));
10906
+ }), t.end(JSON.stringify({ secrets: await this.secretRepository.getAllForJob() }));
10888
10907
  }
10889
10908
  async handleSecretReload(e, t) {
10890
10909
  if (!this.isAdmin(e)) return t.writeHead(403, { "Content-Type": "application/json" }), t.end(JSON.stringify({ error: "Unauthorized" }));
@@ -11522,7 +11541,7 @@ async function Sl(e) {
11522
11541
  })), b = Il(n, "running", p, f, _.env, y, s);
11523
11542
  await o.saveReport(n.id, b), bl(r, n.id);
11524
11543
  try {
11525
- Object.assign(_.env, await El(l.env, _)), Nl(l.secretFiles, a.getAll(), g), v = l.cache ? String(await Zn.evaluateValue(l.cache.key, _)) : "", v && l.cache && await Ml(o, "cache", `${n.workflow_id}:${v}`, l.cache.paths, g), await o.saveReport(n.id, b);
11544
+ Object.assign(_.env, await El(l.env, _)), Nl(l.secretFiles, a, g), v = l.cache ? String(await Zn.evaluateValue(l.cache.key, _)) : "", v && l.cache && await Ml(o, "cache", `${n.workflow_id}:${v}`, l.cache.paths, g), await o.saveReport(n.id, b);
11526
11545
  } catch (e) {
11527
11546
  b.status = "failed", b.finishedAt = (/* @__PURE__ */ new Date()).toISOString(), b.durationMs = Date.now() - p;
11528
11547
  let r = {
@@ -11799,10 +11818,10 @@ async function Ml(t, n, r, a, o) {
11799
11818
  }
11800
11819
  function Nl(t, n, r) {
11801
11820
  for (let [a, o] of Object.entries(t || {})) {
11802
- let t = n[o];
11803
- if (t === void 0) throw Error(`Secret '${o}' is not available for file '${a}'`);
11821
+ let t = n.getValue(o);
11822
+ if (!t) throw Error(`Secret '${o}' is not available for file '${a}'`);
11804
11823
  let s = Ol(r, a);
11805
- e.mkdirSync(i.dirname(s), { recursive: !0 }), e.writeFileSync(s, t, { mode: 384 }), e.chmodSync(s, 384);
11824
+ e.mkdirSync(i.dirname(s), { recursive: !0 }), e.writeFileSync(s, t.encoding === "base64" ? Buffer.from(t.value, "base64") : t.value, { mode: 384 }), e.chmodSync(s, 384);
11806
11825
  }
11807
11826
  }
11808
11827
  async function Pl(t) {
@@ -1 +1 @@
1
- {"version":3,"file":"safe-eval.d.ts","sourceRoot":"","sources":["../src/safe-eval.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAW/C,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM;mBAS9B,MAAM;qBACJ,MAAM;qBACN,MAAM,EAAE;EAE5B;AAED,qBAAa,uBAAuB;IAClC;;;;;OAKG;WACU,aAAa,CAAC,GAAG,EAAE,GAAG,EAAE,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IAerF;;;;OAIG;WACU,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAkBnH;;OAEG;WACU,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IAoB9F;;OAEG;mBACkB,aAAa;CAsKnC"}
1
+ {"version":3,"file":"safe-eval.d.ts","sourceRoot":"","sources":["../src/safe-eval.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAQ/C,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM;mBAS9B,MAAM;qBACJ,MAAM;qBACN,MAAM,EAAE;EAE5B;AAED,qBAAa,uBAAuB;IAClC;;;;;OAKG;WACU,aAAa,CAAC,GAAG,EAAE,GAAG,EAAE,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IAerF;;;;OAIG;WACU,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAkBnH;;OAEG;WACU,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IAoB9F;;OAEG;mBACkB,aAAa;CAsKnC"}
@@ -1,9 +1,15 @@
1
+ export type SecretEncoding = 'utf8' | 'base64';
2
+ export type StoredSecret = {
3
+ value: string;
4
+ encoding: SecretEncoding;
5
+ };
1
6
  export declare class SecretRepository {
2
7
  private key?;
3
8
  init(): Promise<void>;
4
9
  private encryptionKey;
5
- set(name: string, value: string): Promise<void>;
10
+ set(name: string, value: string, encoding?: SecretEncoding): Promise<void>;
6
11
  getAll(): Promise<Record<string, string>>;
12
+ getAllForJob(): Promise<Record<string, StoredSecret>>;
7
13
  names(): Promise<string[]>;
8
14
  delete(name: string): Promise<boolean>;
9
15
  private decrypt;
@@ -1 +1 @@
1
- {"version":3,"file":"secret-repository.d.ts","sourceRoot":"","sources":["../src/secret-repository.ts"],"names":[],"mappings":"AAWA,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,GAAG,CAAC,CAAS;IAEf,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAO3B,OAAO,CAAC,aAAa;IAIf,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAU/C,MAAM,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAKzC,KAAK,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAK1B,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAK5C,OAAO,CAAC,OAAO;CAMhB"}
1
+ {"version":3,"file":"secret-repository.d.ts","sourceRoot":"","sources":["../src/secret-repository.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,QAAQ,CAAC;AAC/C,MAAM,MAAM,YAAY,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,cAAc,CAAA;CAAE,CAAC;AASvE,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,GAAG,CAAC,CAAS;IAEf,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAS3B,OAAO,CAAC,aAAa;IAIf,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,GAAE,cAAuB,GAAG,OAAO,CAAC,IAAI,CAAC;IAYlF,MAAM,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAKzC,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAKrD,KAAK,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAK1B,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAK5C,OAAO,CAAC,OAAO;CAOhB"}
package/dist/secrets.d.ts CHANGED
@@ -1,11 +1,13 @@
1
+ import type { StoredSecret } from './secret-repository.js';
1
2
  export declare class SecretStore {
2
3
  private secrets;
3
4
  /** Holds job-scoped secrets supplied by the control plane. */
4
5
  constructor();
5
6
  reload(): void;
6
7
  get(key: string): string | undefined;
8
+ getValue(key: string): StoredSecret | undefined;
7
9
  getAll(): Record<string, string>;
8
- replace(values: Record<string, string>): void;
10
+ replace(values: Record<string, string | StoredSecret>): void;
9
11
  redactText(text: string): string;
10
12
  }
11
13
  //# sourceMappingURL=secrets.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"secrets.d.ts","sourceRoot":"","sources":["../src/secrets.ts"],"names":[],"mappings":"AACA,qBAAa,WAAW;IACtB,OAAO,CAAC,OAAO,CAA6B;IAE5C,8DAA8D;;IAK9D,MAAM;IAGN,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIpC,MAAM,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAIhC,OAAO,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAItC,UAAU,CAAC,IAAI,EAAE,MAAM;CAaxB"}
1
+ {"version":3,"file":"secrets.d.ts","sourceRoot":"","sources":["../src/secrets.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAE3D,qBAAa,WAAW;IACtB,OAAO,CAAC,OAAO,CAAmC;IAElD,8DAA8D;;IAK9D,MAAM;IAGN,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIpC,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS;IAI/C,MAAM,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAIhC,OAAO,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAAC;IAIrD,UAAU,CAAC,IAAI,EAAE,MAAM;CAaxB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud-cli/on",
3
- "version": "1.38.0",
3
+ "version": "1.39.0",
4
4
  "description": "CLI entry point for the on plugin ecosystem.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.3",
@@ -42,7 +42,7 @@
42
42
  "tsx": "^4.23.13",
43
43
  "typescript": "^6.0.3",
44
44
  "typescript-eslint": "^8.70.0",
45
- "vite": "^8.2.2",
45
+ "vite": "^8.3.0",
46
46
  "vitest": "^4.1.11"
47
47
  }
48
48
  }