devcontainers / devcontainers/cli

Feature request: First-class array input for Feature options (array option type + multiple invocations) [blocked on spec#766]

Open
#1,298 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
3k
Forks
457
Avg merge
13h 17m
Merged PRs (30d)
6

Description

# Feature request: First-class array input for Feature options (array option type + multiple invocations)

> **Complex feature request — depends on the spec change in
> [devcontainers/spec#766](https://github.com/devcontainers/spec/issues/766) (RFC).**
> This issue tracks the **reference CLI implementation** work once the spec RFC is accepted.
> Related long-standing issues: [spec#57](https://github.com/devcontainers/spec/issues/57)
> (array option type, open since 2022) and [spec#44](https://github.com/devcontainers/spec/issues/44)
> (install a feature more than once).

---

## Problem

The dev container spec restricts Feature option values to `boolean` and `string`. There is **no
array type**. As a result, every Feature that needs a list (packages, extensions, tools, versions)
is forced to accept a **comma-separated string** and split it inside `install.sh`.

This CLI encodes that limitation directly in its TypeScript types, which is the root cause that
blocks any progress:

```ts
// src/spec-configuration/containerFeaturesConfiguration.ts
export type FeatureOption = {
type: 'boolean';
default?: boolean;
description?: string;
} | {
type: 'string';
enum?: string[];
default?: string;
description?: string;
} | {
type: 'string';
proposals?: string[];
default?: string;
description?: string;
};
```

```ts
// src/spec-configuration/configuration.ts
export interface DevContainerFeature {
userFeatureId: string;
options: boolean | string | Record;
}
// ...
features?: Record>;
```

`FeatureOption` has no `'array'` variant. `DevContainerFeature.options` and the `features` map
only permit `boolean | string` for option values — **arrays are not representable**, so they can
never reach `install.sh` no matter what a user writes in `devcontainer.json`.

### Real-world impact (shipped Features, today)

| Feature | Option | Today | Symptom |
|---|---|---|---|
| `ghcr.io/rocker-org/devcontainer-features/r-packages:1` | `packages` | `"cli,rlang"` | Comma-joined; values containing commas are unrepresentable. |
| `ghcr.io/devcontainers/features/github-cli` | extensions | `"github/gh-copilot"` | Comma-joined; extension refs/args with commas break. |
| `mwmahlberg/devcontainer-features` `npm-packages` | `packages` | `"typescript,eslint"` | Accepts comma **or** whitespace **or** newline — three delimiters, because the spec gives no canonical list form. |

The `npm-packages` case is the clearest failure signal: with no array type, **every Feature author
invents a different delimiter**. Consumers cannot reason about a list option without reading each
Feature's `install.sh`.

This was always meant to be temporary. From [spec#57](https://github.com/devcontainers/spec/issues/57),
maintainer @Chuxel (2022):

> Right now things in devcontainers/features are using a comma separated string as a **near term
> workaround**. Converting this into an array is pretty easy…

That workaround has now been the de facto standard for **3+ years**.

---

## Requirements (hard — no alternatives)

This is a **required** capability, not a nice-to-have. Comma-separated strings are not an acceptable
long-term substitute (they are ambiguous, lossy, and force per-Feature delimiter conventions). The
CLI must implement:

1. **`array` option type** — `devcontainer-feature.json` may declare `"type": "array"` for an
option, with `default` as an array and `proposals`/`enum` constraining **elements**.
2. **Array option values in `devcontainer.json`** — `"packages": ["curl","git","jq"]` must be
accepted, validated, and propagated.
3. **Multiple invocations via array of option objects** — a Feature value may be an array of option
objects (`"dotnet": [{"version":"3.1"},{"version":"6.0"}]`), invoking `install.sh` once per
element in order (resolves spec#44).
4. **Option Resolution for arrays** — array values are serialized to `devcontainer-features.env` as
a **JSON array string** (`PACKAGES='["curl","git","jq"]'`), the only delimiter-free, unambiguous
encoding.
5. **Backward-compatible string→array coercion** — if a string is supplied for an `array`-typed
option: parse as JSON if it looks like a JSON array, else split on commas. Existing
comma-separated Features keep working when they migrate to `type: "array"`.
6. **CLI flag support** — accept JSON array values in `--override-features`, and support repeated
flags (`--feature-option . `) that append to an array option.

---

## Proposed implementation

### 1. Type changes

**`src/spec-configuration/containerFeaturesConfiguration.ts`** — extend `FeatureOption`:

```ts
export type FeatureOption = {
type: 'boolean';
default?: boolean;
description?: string;
} | {
type: 'string';
enum?: string[];
default?: string;
description?: string;
} | {
type: 'string';
proposals?: string[];
default?: string;
description?: string;
} | {
type: 'array'; // NEW
enum?: string[]; // constrains elements
proposals?: string[]; // suggests elements
default?: (string | boolean | number)[];// array default
description?: string;
};
```

**`src/spec-configuration/configuration.ts`** — widen option value and Feature value types:

```ts
export interface DevContainerFeature {
userFeatureId: string;
// allow arrays of primitives as option values, and array-of-option-objects as the Feature value
options: boolean | string | (string | boolean | number)[] | Record | Record[];
}
// ...
features?: Record | Record[]>;
```

### 2. Option parsing & normalization

Where feature option values are read and normalized (the `getFeatureValueDefaults` /
option-resolution path in `containerFeaturesConfiguration.ts`):

- When `option.type === 'array'`:
- Accept a JSON array value as-is.
- If the supplied value is a string, attempt `JSON.parse`; if it yields an array, use it;
otherwise split on `,` and trim each element. Emit a warning recommending the array form.
- Validate each element against `enum`/`proposals` when present.
- Default to `[]` when omitted and no `default`.

### 3. Option Resolution (env serialization)

In the code that writes `devcontainer-features.env` (`=`):

- For an `array` option, serialize the value with `JSON.stringify(value)` so the env var holds the
canonical JSON array string. Example output:
```env
PACKAGES='["curl","git","jq"]'
```
- This keeps a single, unambiguous source of truth. `install.sh` parses with `jq` (already
ubiquitous in dev container images):
```bash
for pkg in $(printf '%s' "$PACKAGES" | jq -r '.[]'); do apt-get install -y "$pkg"; done
```

### 4. Multiple invocations (array of option objects)

In the feature install layer (`getFeatureLayers` / `getFeatureInstallWrapperScript` and the
surrounding orchestration):

- When a Feature's value is an array of option objects, emit one install layer/wrapper per element,
in array order, each with its own `devcontainer-features.env`. All invocations of a given Feature
run consecutively at that Feature's position in the install order (do not interleave with other
Features).

### 5. CLI flags

- `--override-features`: already accepts a JSON blob; ensure array option values and array-of-objects
Feature values parse and flow through the new types.
- Add/confirm a repeated-flag form: `--feature-option . ` appends to an
array-typed option (and sets/replaces for scalar options).

### 6. Validation & errors

- Reject non-array values for `array`-typed options (after string coercion) with a clear error
naming the Feature and option.
- Validate `enum` elements; report the offending element.

---

## Test plan

- [ ] `devcontainer-feature.json` with `type: "array"` parses and surfaces `default`/`enum`/`proposals`.
- [ ] `devcontainer.json` with `"packages": ["curl","git"]` reaches `install.sh` as
`PACKAGES='["curl","git"]'`.
- [ ] String `"curl,git"` for an `array` option coerces to `["curl","git"]` with a warning.
- [ ] String `'["curl","git"]'` (JSON) coerces to the array without warning.
- [ ] `enum`-constrained array option rejects an out-of-set element.
- [ ] Array-of-option-objects Feature value invokes `install.sh` twice with distinct env vars, in
order.
- [ ] Repeated `--feature-option` flags append to an array option.
- [ ] Existing comma-separated Features (e.g. `r-packages`) continue to work unchanged after
migrating their option to `type: "array"`.
- [ ] JSON Schema (`devContainerFeature.schema.json`) validates the new shapes.

---

## Use cases

1. **Package lists** — `r-packages`, `npm-packages`, `github-cli` extensions, Homebrew formulae:
explicit arrays, values may contain commas.
2. **Multiple runtime versions** — install `.NET 3.1` and `6.0` (or Node `18` + `20`) in one image
via array-of-option-objects, without bespoke per-Feature "multi-version" options.
3. **Multi-select tool bundles** — a Feature offering a curated subset of tools; with `enum`
elements the UX can render a multi-select picker.
4. **Tool-readable config** — schema validation, IntelliSense, and diffs work natively on JSON
arrays; comma-strings are opaque to every tool except the splitting `install.sh`.

---

## Prior art (alternative stacks)

The dev container spec is the **only** major dev-environment format lacking a native list type for
user-supplied option values:

| Stack | List input | Notes |
|---|---|---|
| **Coder** | `coder_parameter` `type = "list(string)"`; UI `multi-select`/`tag-select`; defaults via `jsonencode([...])` | First-class list type. Coder's docs warn that overriding `list(string)` on the CLI is *"tricky"* (CSV+JSON quoting) and offer a YAML workaround — exactly the ambiguity this CLI should avoid by defining array semantics up front. |
| **Nix** (`mkShell`) | Native lists `packages = [ curl git jq ];` | First-class; no string parsing. |
| **Gitpod** (`.gitpod.yml`) | Native YAML arrays (`tasks`, `ports`, `vscode.extensions`) | First-class. |
| **Docker Compose** | Native YAML arrays (`volumes`, `ports`, `environment`) | First-class. |
| **Helm** | Native YAML arrays in `values.yaml`, iterated with `range` | First-class. |
| **Terraform** | `list(string)`, `list(any)` native variable types | First-class. |
| **Dev Containers (this CLI)** | ❌ No array option type — comma-separated `string` only | **Outlier.** |

---

## Dependencies & unblocking

- **Blocked on spec acceptance**: [devcontainers/spec#766](https://github.com/devcontainers/spec/issues/766)
(the RFC defining `array` option type, Option Resolution for arrays, and array-of-option-objects).
- **Closes the long-standing workaround**: [spec#57](https://github.com/devcontainers/spec/issues/57)
(2022) and [spec#44](https://github.com/devcontainers/spec/issues/44).

Once the spec RFC lands, this issue is the implementation tracker for the reference CLI. The type
changes in §1 are the minimal unblocking step; everything else follows from the spec's normative
requirements.

---

## References

- Spec RFC: https://github.com/devcontainers/spec/issues/766
- Original array-option request: https://github.com/devcontainers/spec/issues/57
- Multiple invocations: https://github.com/devcontainers/spec/issues/44
- Spec option resolution: https://containers.dev/implementors/features/#option-resolution
- Coder `list(string)` parameters: https://coder.com/docs/admin/templates/extending-templates/parameters

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.