diff --git a/projects/start-os/CHANGELOG.md b/projects/start-os/CHANGELOG.md
index a1a9cb659..76cd73353 100644
--- a/projects/start-os/CHANGELOG.md
+++ b/projects/start-os/CHANGELOG.md
@@ -70,6 +70,11 @@ file tracks notable changes since the move to the monorepo.
any browser pointed at this server. Narrow windows and phones always show the
grid.
+- **An action can return a multi-line value** — a diagnostic report, a
+ generated config file, an exported key block. It appears as a read-only
+ monospace box that keeps its line breaks, and, where the service asks for it,
+ can be copied, shown as a QR code, or saved to a file.
+
### Changed
- **Your server's name is now its `.local` address, without the `.local` on the
diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-group.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-group.component.ts
index 3c1f455c8..8f7393233 100644
--- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-group.component.ts
+++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-group.component.ts
@@ -1,39 +1,48 @@
import { Component, Input } from '@angular/core'
import { TuiAccordion, TuiFade } from '@taiga-ui/kit'
import { ActionSuccessMemberComponent } from './action-success-member.component'
+import { ActionSuccessMultilineComponent } from './action-success-multiline.component'
import { GroupResult } from './types'
@Component({
selector: 'app-action-success-group',
template: `
@for (member of group.value; track $index) {
-
+ @if (member.type === 'single') {
+
+ }
+ @if (member.type === 'multiline') {
+
+ }
+ @if (member.type === 'group') {
+
+
+
+
+
+
+ }
}
`,
styles: `
- p:first-child {
- margin-top: 0;
- }
-
- p:last-child {
- margin-bottom: 0;
+ :host {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
}
`,
- imports: [ActionSuccessMemberComponent, TuiAccordion, TuiFade],
+ imports: [
+ ActionSuccessMemberComponent,
+ ActionSuccessMultilineComponent,
+ TuiAccordion,
+ TuiFade,
+ ],
})
export class ActionSuccessGroupComponent {
@Input()
diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-multiline.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-multiline.component.ts
new file mode 100644
index 000000000..d1df408ab
--- /dev/null
+++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-multiline.component.ts
@@ -0,0 +1,152 @@
+import {
+ Component,
+ computed,
+ inject,
+ input,
+ linkedSignal,
+ TemplateRef,
+} from '@angular/core'
+import { CopyService, DialogService, i18nPipe } from '@start9labs/shared'
+import { TuiButton, TuiTitle } from '@taiga-ui/core'
+import { TuiTextarea } from '@taiga-ui/kit'
+import { QRComponent } from 'src/app/routes/portal/components/qr.component'
+import { MultilineResult } from './types'
+
+@Component({
+ selector: 'app-action-success-multiline',
+ template: `
+ @if (name()) {
+
+ {{ name() }}
+ @if (description()) {
+ {{ description() }}
+ }
+
+ }
+
+
+ @if (multiline().masked) {
+
+ }
+ @if (multiline().copyable) {
+
+ }
+ @if (multiline().qr) {
+
+ }
+ @if (multiline().filename; as filename) {
+
+ {{ 'Download' | i18n }}
+
+ }
+
+
+
+ @if (masked()) {
+
+ }
+
+ `,
+ styles: `
+ @use '@taiga-ui/styles/utils' as taiga;
+
+ :host {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ tui-textfield {
+ font-family: var(--tui-typography-family-code);
+ }
+
+ .reveal {
+ @include taiga.center-all();
+ }
+ `,
+ imports: [TuiButton, TuiTextarea, TuiTitle, QRComponent, i18nPipe],
+})
+export class ActionSuccessMultilineComponent {
+ private readonly dialog = inject(DialogService)
+ readonly copy = inject(CopyService)
+
+ readonly multiline = input.required()
+ readonly name = input('')
+ readonly description = input('')
+
+ protected readonly masked = linkedSignal(() => this.multiline().masked)
+ protected readonly href = computed(() =>
+ URL.createObjectURL(
+ new Blob([this.multiline().value], { type: 'application/octet-stream' }),
+ ),
+ )
+
+ protected show(template: TemplateRef) {
+ const masked = this.masked()
+
+ this.masked.set(this.multiline().masked)
+ this.dialog
+ .openComponent(template, { label: 'Scan this QR', size: 's' })
+ .subscribe({ complete: () => this.masked.set(masked) })
+ }
+}
diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success.page.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success.page.ts
index 7b1d7d95e..62851fb27 100644
--- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success.page.ts
+++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/action-success.page.ts
@@ -9,6 +9,7 @@ import { TuiDialogContext } from '@taiga-ui/core'
import { NgDompurifyPipe } from '@taiga-ui/dompurify'
import { injectContext } from '@taiga-ui/polymorpheus'
import { ActionSuccessGroupComponent } from './action-success-group.component'
+import { ActionSuccessMultilineComponent } from './action-success-multiline.component'
import { ActionSuccessSingleComponent } from './action-success-single.component'
import { ActionResponse } from './types'
@@ -24,12 +25,23 @@ import { ActionResponse } from './types'
@if (single) {
}
+ @if (multiline) {
+
+ }
@if (group) {
}
`,
+ styles: `
+ :host {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ }
+ `,
imports: [
ActionSuccessGroupComponent,
+ ActionSuccessMultilineComponent,
ActionSuccessSingleComponent,
NgDompurifyPipe,
MarkdownPipe,
@@ -43,6 +55,8 @@ export class ActionSuccessPage {
readonly message = this.data.message as i18nKey | null
readonly single =
this.data.result?.type === 'single' ? this.data.result : null
+ readonly multiline =
+ this.data.result?.type === 'multiline' ? this.data.result : null
readonly group = this.data.result?.type === 'group' ? this.data.result : null
readonly options = { breaks: true }
diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/types.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/types.ts
index 91835b7e7..80b6881b5 100644
--- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/types.ts
+++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/modals/action-success/types.ts
@@ -3,4 +3,5 @@ import { ActionRes } from 'src/app/services/api/api.types'
export type ActionResponse = NonNullable
type ActionResult = NonNullable
export type SingleResult = ActionResult & { type: 'single' }
+export type MultilineResult = ActionResult & { type: 'multiline' }
export type GroupResult = ActionResult & { type: 'group' }
diff --git a/projects/start-os/web/ui/src/app/services/action.service.ts b/projects/start-os/web/ui/src/app/services/action.service.ts
index fceb0cdb0..d74644d7b 100644
--- a/projects/start-os/web/ui/src/app/services/action.service.ts
+++ b/projects/start-os/web/ui/src/app/services/action.service.ts
@@ -1,5 +1,6 @@
import { inject, Injectable } from '@angular/core'
import { DialogService, getErrorMessage, i18nKey } from '@start9labs/shared'
+import { T } from '@start9labs/start-core'
import { TuiNotificationMiddleService } from '@taiga-ui/kit'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { filter } from 'rxjs'
@@ -68,6 +69,7 @@ export class ActionService {
.openComponent(new PolymorpheusComponent(ActionSuccessPage), {
label: res.title as i18nKey,
data: res,
+ size: res.result && hasMultiline(res.result) ? 'l' : 'm',
})
.subscribe()
}
@@ -80,3 +82,9 @@ export class ActionService {
}
}
}
+
+function hasMultiline(value: T.ActionResultValue): boolean {
+ return value.type === 'group'
+ ? value.value.some(hasMultiline)
+ : value.type === 'multiline'
+}
diff --git a/projects/start-os/web/ui/src/app/services/api/api.fixures.ts b/projects/start-os/web/ui/src/app/services/api/api.fixures.ts
index 2bc56204f..e81e76757 100644
--- a/projects/start-os/web/ui/src/app/services/api/api.fixures.ts
+++ b/projects/start-os/web/ui/src/app/services/api/api.fixures.ts
@@ -1648,6 +1648,114 @@ Full changelog: https://github.com/Kixunil/btc-rpc-proxy/blob/master/CHANGELOG.m
},
}
+ const DOCTOR_REPORT = `Vikunja Doctor
+==============
+
+Checked against https://vikunja.embassy at 2026-08-28T14:02:11Z
+
+Component Status Detail
+----------------- -------- ---------------------------------------
+Database ok postgres 16.3, 41 tables, 0 pending
+Typesense index degraded 4812 of 5104 tasks indexed
+Attachment store ok 1.2 GiB across 318 files
+Mailer skipped no SMTP credentials configured
+Public URL ok resolves to 10.0.1.24:3456
+
+2 warnings
+ - The search index is behind. Run "Reindex" to rebuild it.
+ - The mailer is unconfigured, so reminders and invitations are
+ silently dropped.`
+
+ const DEVICE_CONFIG = `[Interface]
+PrivateKey = qNSHDgIkG9Bo0dnjBRAmvIBaU0MI/ADoWfDaCu9uWFo=
+Address = 10.13.13.4/32
+DNS = 10.13.13.1
+
+[Peer]
+PublicKey = HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw=
+PresharedKey = uUYtV+HDNU9kZ0eDDBTBQfLuIJfHIPRSRfBWMWFBAgk=
+Endpoint = tunnel.start9.com:51820
+AllowedIPs = 0.0.0.0/0, ::/0
+PersistentKeepalive = 25`
+
+ export const ActionResMultiline: ActionRes = {
+ version: '1',
+ title: 'Diagnostics',
+ message: 'Send this report along if you open a support ticket.',
+ result: {
+ type: 'multiline',
+ copyable: true,
+ qr: false,
+ masked: false,
+ filename: 'vikunja-doctor.txt',
+ value: DOCTOR_REPORT,
+ },
+ }
+
+ export const ActionResMultilineSecret: ActionRes = {
+ version: '1',
+ title: 'Device Added',
+ message: 'Scan this from the WireGuard app, or save it as a file.',
+ result: {
+ type: 'multiline',
+ copyable: true,
+ qr: true,
+ masked: true,
+ filename: 'start-tunnel.conf',
+ value: DEVICE_CONFIG,
+ },
+ }
+
+ export const ActionResMultilineGroup: ActionRes = {
+ version: '1',
+ title: 'Service Information',
+ message: 'Everything StartOS could collect about this service.',
+ result: {
+ type: 'group',
+ value: [
+ {
+ type: 'single',
+ name: 'Version',
+ description: null,
+ copyable: false,
+ qr: false,
+ masked: false,
+ value: '0.24.6',
+ },
+ {
+ type: 'multiline',
+ name: 'Doctor Report',
+ description: 'The full output of `vikunja doctor`.',
+ copyable: true,
+ qr: false,
+ masked: false,
+ filename: 'vikunja-doctor.txt',
+ value: DOCTOR_REPORT,
+ },
+ {
+ type: 'multiline',
+ name: 'Device Config',
+ description: 'The WireGuard config for the device you just added.',
+ copyable: true,
+ qr: true,
+ masked: true,
+ filename: 'start-tunnel.conf',
+ value: DEVICE_CONFIG,
+ },
+ {
+ type: 'multiline',
+ name: 'Recovery Phrase',
+ description: 'Write this down. It is shown only once.',
+ copyable: true,
+ qr: false,
+ masked: true,
+ value:
+ 'shrug cinnamon plunge oyster\nharbor velvet timber acorn\nglisten fossil marble rooster',
+ },
+ ],
+ },
+ }
+
export const getCreateOnionServiceSpec = async (): Promise =>
configBuilderToSpec(
ISB.InputSpec.of({
diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts
index 55845e40c..0477a1d99 100644
--- a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts
+++ b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts
@@ -1312,6 +1312,11 @@ export class MockApiService extends ApiService {
// return Mock.ActionResSingle
if (params.actionId === 'big-qr') return Mock.ActionResBigQr
if (params.actionId === 'unencodable-qr') return Mock.ActionResUnencodableQr
+ if (params.actionId === 'multiline') return Mock.ActionResMultiline
+ if (params.actionId === 'multiline-secret')
+ return Mock.ActionResMultilineSecret
+ if (params.actionId === 'multiline-group')
+ return Mock.ActionResMultilineGroup
return Mock.ActionResMessage
}
diff --git a/projects/start-os/web/ui/src/app/services/api/mock-patch.ts b/projects/start-os/web/ui/src/app/services/api/mock-patch.ts
index 85a823e00..a9b1241ac 100644
--- a/projects/start-os/web/ui/src/app/services/api/mock-patch.ts
+++ b/projects/start-os/web/ui/src/app/services/api/mock-patch.ts
@@ -521,6 +521,36 @@ export const mockPatchData: DataModel = {
hasInput: false,
group: null,
},
+ multiline: {
+ name: 'Show Report',
+ description:
+ 'Returns a multi-line value with a copy button and a download',
+ warning: null,
+ visibility: 'enabled',
+ allowedStatuses: 'any',
+ hasInput: false,
+ group: null,
+ },
+ 'multiline-secret': {
+ name: 'Show Device Config',
+ description:
+ 'Returns a masked multi-line value that can also be shown as a QR code',
+ warning: null,
+ visibility: 'enabled',
+ allowedStatuses: 'any',
+ hasInput: false,
+ group: null,
+ },
+ 'multiline-group': {
+ name: 'Show Mixed Group',
+ description:
+ 'Returns a group whose members mix single-line and multi-line values',
+ warning: null,
+ visibility: 'enabled',
+ allowedStatuses: 'any',
+ hasInput: false,
+ group: null,
+ },
test: {
name: 'Do Another Thing',
description:
diff --git a/projects/start-sdk/CHANGELOG.md b/projects/start-sdk/CHANGELOG.md
index d38eb1777..d2338cee7 100644
--- a/projects/start-sdk/CHANGELOG.md
+++ b/projects/start-sdk/CHANGELOG.md
@@ -117,6 +117,12 @@
alongside it and default to `false`. See
[Single Value](https://docs.start9.com/packaging/actions.html#single-value)
+- **An action result can be a `multiline` value**: a read-only monospace box
+ that keeps its line breaks, taking the same optional `copyable` / `qr` /
+ `masked` flags as `single`, plus an optional `filename` that offers it as a
+ download. A `single` value is one line, and a newline in one is not rendered.
+ See [Result Types](https://docs.start9.com/packaging/actions.html#result-types)
+
### Fixed
- **Scaffolded package CI builds a draft PR when it becomes ready and rebuilds
diff --git a/projects/start-sdk/docs/src/actions.md b/projects/start-sdk/docs/src/actions.md
index 5e8f961d7..81af4ae0f 100644
--- a/projects/start-sdk/docs/src/actions.md
+++ b/projects/start-sdk/docs/src/actions.md
@@ -94,9 +94,7 @@ export const actions = sdk.Actions.of().addAction(setAdminPassword)
Actions return structured results that the StartOS UI renders for the user.
-`message` is prose shown under the title. It is rendered as **Markdown** — headings, lists, tables, code blocks, emphasis and links all work — and a single newline is kept as a line break, so text written as plain lines arrives as plain lines. Anything with its own line structure goes here.
-
-`result` holds discrete values the user acts on: each one is a single-line field with optional copy, QR, masking and link-opening, so a newline in a `value` is not rendered. A multi-line report goes in `message`, not in a `value`.
+`message` is prose shown under the title. It is rendered as **Markdown** — headings, lists, tables, code blocks, emphasis and links all work — and a single newline is kept as a line break, so text written as plain lines arrives as plain lines. Guidance and next steps go here.
```typescript
return {
@@ -112,6 +110,16 @@ Restart the service once the rebuild finishes.`,
}
```
+`result` holds the values the user acts on — copies, scans, or saves. It takes one of three types:
+
+| `type` | Renders as | Takes |
+| ----------- | ------------------------------------------------------------------ | --------------------------------------------------------------- |
+| `single` | a one-line field | `value`, plus optional `copyable`, `qr`, `masked`, `launchable` |
+| `multiline` | a read-only monospace box that keeps its line breaks | `value`, plus optional `copyable`, `qr`, `masked`, `filename` |
+| `group` | an accordion of named members, each of which is any of these three | `value`, the array of members |
+
+A newline in a `single` value is not rendered — the browser strips it from the field — so anything with its own line structure is a `multiline` value. `filename` is what separates "here is some text" from "here is a file": set it and the value is also offered as a download under that name; omit it for no download button.
+
### Single Value
```typescript
@@ -140,14 +148,28 @@ result: {
The value must be an `http(s)` URL for the button to go anywhere.
+### Multi-line Value
+
+```typescript
+result: {
+ type: 'multiline',
+ value: report,
+ copyable: true,
+ filename: 'vikunja-doctor.txt',
+}
+```
+
### Group of Values
+A member carries a `name`, and an optional `description`, on top of whatever its own type takes:
+
```typescript
result: {
type: 'group',
value: [
{ type: 'single', name: 'Username', description: null, value: 'admin', masked: false, copyable: true, qr: false },
{ type: 'single', name: 'Password', description: null, value: 'secret', masked: true, copyable: true, qr: false },
+ { type: 'multiline', name: 'Device Config', description: null, value: config, masked: true, copyable: true, qr: true, filename: 'start-tunnel.conf' },
],
}
```
diff --git a/shared-libs/crates/start-core/src/action.rs b/shared-libs/crates/start-core/src/action.rs
index 88d52ed52..33e7c1253 100644
--- a/shared-libs/crates/start-core/src/action.rs
+++ b/shared-libs/crates/start-core/src/action.rs
@@ -191,7 +191,7 @@ pub struct ActionResultMember {
pub enum ActionResultValue {
Single {
/// The actual string value to display. The UI renders it as a single-line field —
- /// multi-line text belongs in the result's `message`.
+ /// multi-line text belongs in a `multiline` value.
value: String,
/// (optional) Whether or not to include a copy to clipboard icon to copy the value
#[ts(optional)]
@@ -206,6 +206,22 @@ pub enum ActionResultValue {
#[ts(optional)]
launchable: Option,
},
+ Multiline {
+ /// The actual string value to display. The UI renders it verbatim in a read-only monospace field that keeps its line breaks
+ value: String,
+ /// (optional) Whether or not to include a copy to clipboard icon to copy the value
+ #[ts(optional)]
+ copyable: Option,
+ /// (optional) Whether or not to also display the value as a QR code
+ #[ts(optional)]
+ qr: Option,
+ /// (optional) Whether or not to blur the value until the user reveals it, which is useful for a private key or other sensitive information
+ #[ts(optional)]
+ masked: Option,
+ /// (optional) Also offer the value as a download under this file name, such as "diagnostics.txt"
+ #[ts(optional)]
+ filename: Option,
+ },
Group {
/// An new group of nested values, experienced by the user as an accordion dropdown
value: Vec,
@@ -214,11 +230,16 @@ pub enum ActionResultValue {
impl ActionResultValue {
fn fmt_rec(&self, f: &mut fmt::Formatter<'_>, indent: usize) -> fmt::Result {
match self {
- Self::Single { value, qr, .. } => {
- for _ in 0..indent {
- write!(f, " ")?;
+ Self::Single { value, qr, .. } | Self::Multiline { value, qr, .. } => {
+ for (i, line) in value.lines().enumerate() {
+ if i > 0 {
+ writeln!(f)?;
+ }
+ for _ in 0..indent {
+ write!(f, " ")?;
+ }
+ write!(f, "{line}")?;
}
- write!(f, "{value}")?;
if qr.unwrap_or_default() {
use qrcode::render::unicode;
writeln!(f)?;
diff --git a/shared-libs/ts-modules/shared/styles/taiga.scss b/shared-libs/ts-modules/shared/styles/taiga.scss
index ec121432c..0d0dfc3ff 100644
--- a/shared-libs/ts-modules/shared/styles/taiga.scss
+++ b/shared-libs/ts-modules/shared/styles/taiga.scss
@@ -166,6 +166,7 @@ tui-textfield [tuiTooltip] {
:root {
--tui-typography-family-text: 'Hanken Grotesk', system-ui;
--tui-typography-family-display: 'Hanken Grotesk', system-ui;
+ --tui-typography-family-code: ui-monospace, monospace;
}
tui-notification-middle {
diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultMember.ts b/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultMember.ts
index b8c5ee5c6..6ad14a571 100644
--- a/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultMember.ts
+++ b/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultMember.ts
@@ -14,7 +14,7 @@ export type ActionResultMember = {
type: 'single'
/**
* The actual string value to display. The UI renders it as a single-line field —
- * multi-line text belongs in the result's `message`.
+ * multi-line text belongs in a `multiline` value.
*/
value: string
/**
@@ -34,6 +34,29 @@ export type ActionResultMember = {
*/
launchable?: boolean
}
+ | {
+ type: 'multiline'
+ /**
+ * The actual string value to display. The UI renders it verbatim in a read-only monospace field that keeps its line breaks
+ */
+ value: string
+ /**
+ * (optional) Whether or not to include a copy to clipboard icon to copy the value
+ */
+ copyable?: boolean
+ /**
+ * (optional) Whether or not to also display the value as a QR code
+ */
+ qr?: boolean
+ /**
+ * (optional) Whether or not to blur the value until the user reveals it, which is useful for a private key or other sensitive information
+ */
+ masked?: boolean
+ /**
+ * (optional) Also offer the value as a download under this file name, such as "diagnostics.txt"
+ */
+ filename?: string
+ }
| {
type: 'group'
/**
diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultValue.ts b/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultValue.ts
index dc2ce28ea..475afdf0b 100644
--- a/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultValue.ts
+++ b/shared-libs/ts-modules/start-core/lib/osBindings/ActionResultValue.ts
@@ -6,7 +6,7 @@ export type ActionResultValue =
type: 'single'
/**
* The actual string value to display. The UI renders it as a single-line field —
- * multi-line text belongs in the result's `message`.
+ * multi-line text belongs in a `multiline` value.
*/
value: string
/**
@@ -26,6 +26,29 @@ export type ActionResultValue =
*/
launchable?: boolean
}
+ | {
+ type: 'multiline'
+ /**
+ * The actual string value to display. The UI renders it verbatim in a read-only monospace field that keeps its line breaks
+ */
+ value: string
+ /**
+ * (optional) Whether or not to include a copy to clipboard icon to copy the value
+ */
+ copyable?: boolean
+ /**
+ * (optional) Whether or not to also display the value as a QR code
+ */
+ qr?: boolean
+ /**
+ * (optional) Whether or not to blur the value until the user reveals it, which is useful for a private key or other sensitive information
+ */
+ masked?: boolean
+ /**
+ * (optional) Also offer the value as a download under this file name, such as "diagnostics.txt"
+ */
+ filename?: string
+ }
| {
type: 'group'
/**