workspaces 0.1.5 → 0.1.7

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e8987b36ff8ba5cfd6c039c911012462661c5da01989e84843188e471c58171
4
- data.tar.gz: 1bae9743875990e29ada15d6b26885d0881eaf84dbf8626bfef1bea779753833
3
+ metadata.gz: 9d9d7e03d386bd8949baf124281f3ac26eb2b49f7fb109a97a8f0a4fb16263fa
4
+ data.tar.gz: 76ab8e2adfeb5a5daea4526a41d6d33991076000bd4bed0f621df6355511341a
5
5
  SHA512:
6
- metadata.gz: 173beba79ca2237d5632e06f7abb7edbc0a46f3a17d283bfdead8e59469bb9383d1de2af97f90c8c9c51e24d2b014aa6a21c63dc83f80ac257c488121f7ae909
7
- data.tar.gz: b98959ea514d65b64d454f928330b86959e417bbf1be83466790ea072cd350757b9daa5ff5c7724877684bc834618eeb6a6d6b20788879dcceba48ca8be32712
6
+ metadata.gz: 7d5c69e3d6c6201d08b3bd70d100b8adaf1eb53f3aba93ab91ae3656ab67d6350239dff291fa706f67b7350e7296656ddb4f82b57b6fb9aa4469b5d3a45fdbb9
7
+ data.tar.gz: aba50059b56131d6f949f9badc2327fe5a3f4d05e384452299e2e2afc45b7c24be7a93cf7d76a9c2bf5b96d1bb3a71938a3cbe3d74451a28e718430ba1784a20
data/DESIGN.md CHANGED
@@ -49,12 +49,14 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
49
49
  - **Failure panel**: prominently above the log, with escaped error details and a matching retry action.
50
50
  - **PR page**: existing workspaces are primary; creating another is a collapsed secondary disclosure. Creation is primary only when none exist.
51
51
  - **Action buttons**: start, prepare/rebuild, restart server, stop server, and refresh
52
+ - **Actions & settings panel**: status-adjacent panel that combines routine lifecycle controls, Open workspace, Refresh status, PR update messaging, and editable environment settings so routine operations stay in one keyboard-reachable region.
52
53
  - **Open workspace**: success-styled link available when the server is ready
53
54
  - **Lifecycle controls**: disabled while busy; helper text distinguishes rebuilding from server-only restart
54
55
  - **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
55
56
  - **Log source switcher**: two native links inside one log panel, with current selection announced through `aria-current`; only the selected bounded log is loaded and refreshed. Setup is the default.
56
57
  - **Preview destination**: a small muted URL in Workspace details, visible before readiness; the primary Open workspace action remains reserved for ready servers.
57
58
  - **Editable environment**: labelled monospace multiline `NAME=value` control for non-secret recipe defaults and added workspace settings; busy state disables the fieldset and save button, values are escaped in HTML, other legacy overrides remain hidden. Omitted default names revert to recipe defaults; omitted added names are unset.
59
+ - **Environment presets (JS-enhanced)**: optional preset selector is hidden by default and revealed only when JavaScript is active and presets exist. Selecting a preset replaces the entire draft editor content and announces unsaved state without submitting. Draft edits remain local until Save & restart.
58
60
  - **Danger Zone**: isolated section on the detail page with explicit loss-of-uncommitted-and-untracked-work copy, an exact-workspace-ID confirmation field, and a destructive action. Deletion remains possible without JavaScript, is disabled while setup is busy, and never appears as a list-row action.
59
61
  - **Workspace favicon**: a small two-pane workspace mark served by the dashboard host. Use `--accent` blue for localhost and `--warning` gold for hosted origins so browser tabs signal the environment without relying on page text. The icon contains no workspace-specific data.
60
62
 
data/README.md CHANGED
@@ -97,6 +97,48 @@ temporarily stashed and restored (including staged state). On a restore conflict
97
97
  the stash is retained for manual recovery. Updating the checkout does not run
98
98
  setup or restart the already-running backend; use Rebuild & restart afterward.
99
99
 
100
+ ### Editable environment presets
101
+
102
+ Recipes can define editable environment defaults and named presets in
103
+ `.workspaces.yml`:
104
+
105
+ ```yaml
106
+ default_editable_env:
107
+ DEMO_MODE: "false"
108
+
109
+ environment_presets:
110
+ Demo:
111
+ DEMO_MODE: "true"
112
+ Demo off:
113
+ DEMO_MODE: "false"
114
+ ```
115
+
116
+ Each preset maps editable variable names to values. In the workspace editor,
117
+ choosing a preset fills the draft and replaces its custom editor lines rather
118
+ than merging with them. Selection does not save or restart the workspace; choose
119
+ **Save & restart** to apply the draft. Presets are not applied automatically,
120
+ and defining them requires no JavaScript edits. Preset keys must be declared in
121
+ `default_editable_env`; recipes reject undeclared keys and secret-like values.
122
+ Hidden environment values are never exposed in the editor or preset choices.
123
+
124
+ Use `create --branch main --preset Demo` to save a preset for a new, still-idle
125
+ workspace. `start ID --preset Demo` applies it before preparing a stopped
126
+ workspace; `restart ID --preset 'Demo off'` switches a running workspace and
127
+ restarts its server. `prepare ID --preset Demo` switches and rebuilds. An
128
+ unknown name is rejected without changing saved settings. Selecting a preset
129
+ replaces the editable settings (including custom editor lines) but preserves
130
+ hidden legacy overrides. A running workspace requires `restart --preset` or
131
+ `prepare --preset`, not `start --preset`.
132
+
133
+ For one-off non-secret tweaks, add repeatable `--set NAME=VALUE` and
134
+ `--unset NAME` flags to `create`, `start`, `prepare`, or `restart`. For example,
135
+ `restart ID --preset Demo --set DEMO_FEATURE=on` applies the preset first,
136
+ then the individual changes, and restarts. Without `--preset`, these flags
137
+ patch current editable settings. Names reserved for the runner and names
138
+ suggesting secrets are rejected; values on the command line may be visible
139
+ in process listings and shell history. Use a secret store, not `--set`, for
140
+ credentials.
141
+
100
142
  The front door is not an authentication boundary. Its host and same-origin checks
101
143
  protect routing and POST actions, but a publicly reachable instance still needs
102
144
  access control such as VPN or SSO. Do not expose workspace backend ports directly.
@@ -49,6 +49,28 @@ module Workspaces
49
49
  options = {}
50
50
  OptionParser.new do |parser|
51
51
  parse_common_options(parser, options)
52
+ parser.on('--preset NAME') { |value| options[:preset] = value } if %w(create start prepare restart).include?(command)
53
+ if %w(create start prepare restart).include?(command)
54
+ parser.on('--set NAME=VALUE') do |value|
55
+ name, separator, setting = value.partition('=')
56
+ raise OptionParser::InvalidArgument, '--set requires NAME=VALUE' if separator.empty?
57
+ EnvironmentOverrides.validate_name!(name)
58
+ EnvironmentOverrides.validate_value!(setting)
59
+ if name.match?(/(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i)
60
+ raise OptionParser::InvalidArgument, 'Secret-like names cannot be set on the command line'
61
+ end
62
+ options[:set] ||= {}
63
+ raise OptionParser::InvalidArgument, 'duplicate --set name' if options[:set].key?(name)
64
+ options[:set][name] = setting
65
+ end
66
+ parser.on('--unset NAME') do |value|
67
+ EnvironmentOverrides.validate_name!(value)
68
+ if value.match?(/(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i)
69
+ raise OptionParser::InvalidArgument, 'Secret-like names cannot be unset on the command line'
70
+ end
71
+ (options[:unset] ||= []) << value
72
+ end
73
+ end
52
74
  parse_tls_options(parser, options) if command == 'serve'
53
75
  end.parse!(argv)
54
76
  options
@@ -102,7 +124,8 @@ module Workspaces
102
124
  end
103
125
 
104
126
  def create(_id, options)
105
- emit(registry.create(**options.slice(:branch, :pr, :new_branch, :from)).describe, options)
127
+ emit(registry.create(**options.slice(:branch, :pr, :new_branch, :from, :preset),
128
+ set: options.fetch(:set, {}), unset: options.fetch(:unset, [])).describe, options)
106
129
  end
107
130
 
108
131
  def list(_id, options)
@@ -114,14 +137,16 @@ module Workspaces
114
137
  end
115
138
 
116
139
  def lifecycle(id, options)
117
- action = proc { run_lifecycle(options.fetch(:command), id) }
140
+ action = proc { run_lifecycle(options.fetch(:command), id, preset: options[:preset],
141
+ set: options.fetch(:set, {}), unset: options.fetch(:unset, [])) }
118
142
  result = options[:json] ? with_stdout_on_stderr(&action) : action.call
119
143
  emit(result, options)
120
144
  raise ArgumentError, result['last_error'] if result['status'] == 'error'
121
145
  end
122
146
 
123
- def run_lifecycle(command, id)
124
- started = command == 'restart' ? coordinator.restart(id) : coordinator.start(id, force: command == 'prepare')
147
+ def run_lifecycle(command, id, preset: nil, set: {}, unset: [])
148
+ started = command == 'restart' ? coordinator.restart(id, preset: preset, set: set, unset: unset) :
149
+ coordinator.start(id, force: command == 'prepare', preset: preset, set: set, unset: unset)
125
150
  if !started && coordinator.snapshot(id)['active']
126
151
  raise ArgumentError, 'Workspace is busy; inspect it with show --json'
127
152
  end
@@ -171,8 +196,8 @@ module Workspaces
171
196
  end
172
197
 
173
198
  def usage
174
- 'Usage: bin/workspaces serve | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--json] | ' \
175
- 'list [--pr NUMBER] [--json] | show/start/prepare/restart/stop ID [--json] | ' \
199
+ 'Usage: bin/workspaces serve | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
200
+ 'list [--pr NUMBER] [--json] | show/start/prepare/restart ID [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | stop ID | ' \
176
201
  'exec ID -- COMMAND... | remove ID [--force]'
177
202
  end
178
203
 
@@ -18,12 +18,12 @@ module Workspaces
18
18
  @workers_mutex = Mutex.new
19
19
  end
20
20
 
21
- def start(id, force: false)
22
- launch(id, restart: false, force: force)
21
+ def start(id, force: false, preset: nil, set: {}, unset: [])
22
+ launch(id, restart: false, force: force, prelaunch: settings_update(id, preset, set, unset))
23
23
  end
24
24
 
25
- def restart(id)
26
- launch(id, restart: true, force: true)
25
+ def restart(id, preset: nil, set: {}, unset: [])
26
+ launch(id, restart: true, force: true, prelaunch: settings_update(id, preset, set, unset))
27
27
  end
28
28
 
29
29
  def update_environment(id, set:, remove:)
@@ -73,6 +73,14 @@ module Workspaces
73
73
 
74
74
  private
75
75
 
76
+ def settings_update(id, preset, set, unset)
77
+ return if !preset && set.empty? && unset.empty?
78
+
79
+ lambda do |_workspace|
80
+ EnvironmentOverrides.new(id).apply_settings(preset: preset, set: set, unset: unset)
81
+ end
82
+ end
83
+
76
84
  def resolve_status(id, status, busy)
77
85
  return busy ? status : 'interrupted' if ACTIVE.include?(status)
78
86
  return status if status == 'error'
@@ -93,7 +101,8 @@ module Workspaces
93
101
  'steps' => entry.fetch('steps', []),
94
102
  'log_tail' => include_log ? log_tail(id, log) : '', 'log_source' => log, 'port' => entry['port'],
95
103
  'environment_keys' => environment_keys(id),
96
- 'editable_environment' => editable_environment(id)
104
+ 'editable_environment' => editable_environment(id),
105
+ 'environment_presets' => environment_presets
97
106
  }
98
107
  end
99
108
 
@@ -106,6 +115,9 @@ module Workspaces
106
115
  lock = acquire_lock(id)
107
116
  return false unless lock
108
117
 
118
+ if prelaunch && !restart && !force && Backend.new(id).running?
119
+ raise ArgumentError, 'Workspace is running; use restart --preset to change its environment'
120
+ end
109
121
  return false if backend_already_running?(id, force, lock)
110
122
  prelaunch&.call(workspace)
111
123
  publish_starting(id, restart)
@@ -220,6 +232,13 @@ module Workspaces
220
232
  {}
221
233
  end
222
234
 
235
+ def environment_presets
236
+ recipe = Recipe.new
237
+ recipe.environment_presets.transform_values { |overlay| recipe.default_editable_env.merge(overlay) }
238
+ rescue Recipe::Invalid
239
+ {}
240
+ end
241
+
223
242
  def update(id, attributes)
224
243
  @state.set(id, attributes.merge('updated_at' => now))
225
244
  end
@@ -13,14 +13,17 @@
13
13
  let completed = Date.parse(root.dataset.completed);
14
14
  let actionsSignature = null;
15
15
  function toggleBusyState(nextBusy) {
16
+ busy = !!nextBusy;
16
17
  root.querySelectorAll('.workspace-actions button').forEach((button) => {
17
- button.disabled = nextBusy;
18
+ button.disabled = busy;
18
19
  });
19
20
  if (window.WorkspaceEnvironment)
20
- window.WorkspaceEnvironment.toggleBusy(nextBusy);
21
- node('remove-form').querySelectorAll('input, button').forEach((control) => {
22
- control.disabled = nextBusy;
23
- });
21
+ window.WorkspaceEnvironment.toggleBusy(busy);
22
+ const removeForm = node('remove-form');
23
+ if (removeForm)
24
+ removeForm.querySelectorAll('input, button').forEach((control) => {
25
+ control.disabled = busy;
26
+ });
24
27
  }
25
28
 
26
29
  function renderActions(actions) {
@@ -40,6 +43,7 @@
40
43
  form.append(button);
41
44
  node('actions').append(form);
42
45
  });
46
+ toggleBusyState(busy);
43
47
  }
44
48
 
45
49
  function renderStep(step) {
@@ -86,8 +90,7 @@
86
90
  const view = data.presentation;
87
91
  if (!view)
88
92
  throw new Error('Restart the front door to load the updated status API.');
89
- busy = view.busy;
90
- toggleBusyState(busy);
93
+ toggleBusyState(view.busy);
91
94
  started = Date.parse(data.prepare_started_at);
92
95
  completed = Date.parse(data.completed_at);
93
96
  renderStatus(view);
@@ -95,6 +98,8 @@
95
98
  node('source-update-message').textContent = data.source_update_message || '';
96
99
  node('progress').replaceChildren(...view.steps.map(renderStep));
97
100
  renderActions(view.actions);
101
+ if (window.WorkspaceEnvironment)
102
+ window.WorkspaceEnvironment.syncFromSnapshot(data);
98
103
  updateLog(data);
99
104
  }
100
105
 
@@ -3,22 +3,145 @@
3
3
  const form = document.getElementById('environment-form');
4
4
  if (!form) return;
5
5
  const status = document.getElementById('environment-status');
6
+ const fieldset = form.querySelector('[data-environment-fieldset]');
7
+ const submit = form.querySelector('[data-env-submit]');
8
+ const editor = document.getElementById('editable-env');
9
+ const presetButtons = Array.from(form.querySelectorAll('[data-environment-preset]'));
10
+ const presetGroup = form.querySelector('[data-preset-controls]');
11
+ const initialDraft = editor ? editor.value : '';
12
+ let lastAnnounced = '';
13
+ let activeBusy = document.getElementById('workspace').dataset.busy === 'true';
14
+ let presets = {};
15
+ let selectedPreset = null;
16
+
17
+ try {
18
+ presets = JSON.parse(form.dataset.environmentPresets || '{}');
19
+ } catch (_error) {
20
+ presets = {};
21
+ }
22
+ const hasPresets = Object.keys(presets).length > 0;
23
+ if (presetGroup && hasPresets) presetGroup.hidden = false;
24
+
25
+ function normalize(value) {
26
+ return (value || '')
27
+ .replace(/\r\n?/g, '\n')
28
+ .trimEnd();
29
+ }
30
+
31
+ function matchingPresetNameForDraft(value) {
32
+ const normalizedValue = normalize(value);
33
+ return Object.keys(presets).find(
34
+ (name) => normalize(presets[name]) === normalizedValue
35
+ ) || null;
36
+ }
37
+
38
+ function updatePresetButtons(nextSelected) {
39
+ selectedPreset = nextSelected || null;
40
+ presetButtons.forEach((button) => {
41
+ const isSelected = !!selectedPreset && button.dataset.environmentPreset === selectedPreset;
42
+ button.setAttribute('aria-pressed', isSelected ? 'true' : 'false');
43
+ if (isSelected) button.classList.add('button--success');
44
+ else button.classList.remove('button--success');
45
+ });
46
+ }
47
+
48
+ function setStatus(message) {
49
+ if (!message) return;
50
+ if (lastAnnounced === message) return;
51
+ status.textContent = message;
52
+ lastAnnounced = message;
53
+ }
6
54
 
7
55
  function toggleBusy(nextBusy) {
8
- form.querySelectorAll('fieldset, [data-env-submit]').forEach((control) => {
9
- control.disabled = nextBusy;
56
+ activeBusy = !!nextBusy;
57
+ if (fieldset) fieldset.disabled = activeBusy;
58
+ if (submit) submit.disabled = activeBusy;
59
+ presetButtons.forEach((button) => {
60
+ button.disabled = activeBusy;
10
61
  });
11
62
  }
12
63
 
13
64
  function updateStatus(busy) {
14
65
  if (busy)
15
- status.textContent =
16
- 'Workspace is busy. Environment updates are temporarily disabled.';
66
+ setStatus(
67
+ 'Workspace is busy. Environment updates are temporarily disabled.'
68
+ );
69
+ else if (status.textContent === 'Workspace is busy. Environment updates are temporarily disabled.') {
70
+ status.textContent = '';
71
+ lastAnnounced = '';
72
+ }
73
+ }
74
+
75
+ function announceUnsaved(message) {
76
+ if (activeBusy) return;
77
+ const nextMessage = message || 'Draft updated locally and not saved yet. Use Save & restart to apply changes.';
78
+ status.textContent = nextMessage;
79
+ lastAnnounced = nextMessage;
80
+ }
81
+
82
+ function replaceDraft(value, message) {
83
+ if (!editor) return;
84
+ editor.value = value || '';
85
+ updatePresetButtons(matchingPresetNameForDraft(editor.value));
86
+ announceUnsaved(message);
87
+ }
88
+
89
+ function clearStatus() {
90
+ if (activeBusy) return;
91
+ status.textContent = '';
92
+ lastAnnounced = '';
17
93
  }
18
94
 
19
95
  form.addEventListener('submit', () => {
20
- status.textContent = 'Submitting environment settings…';
96
+ setStatus('Submitting environment settings…');
97
+ });
98
+
99
+ if (editor)
100
+ editor.addEventListener('input', () => {
101
+ if (editor.value === initialDraft) {
102
+ updatePresetButtons(matchingPresetNameForDraft(initialDraft));
103
+ clearStatus();
104
+ return;
105
+ }
106
+ updatePresetButtons(matchingPresetNameForDraft(editor.value));
107
+ announceUnsaved();
108
+ });
109
+
110
+ presetButtons.forEach((button) => {
111
+ button.addEventListener('click', () => {
112
+ if (activeBusy) return;
113
+ const nextValue = button.dataset.environmentPreset;
114
+ if (!nextValue) return;
115
+ if (!Object.prototype.hasOwnProperty.call(presets, nextValue)) return;
116
+ replaceDraft(
117
+ presets[nextValue],
118
+ 'Preset loaded into draft only. Use Save & restart to apply changes.'
119
+ );
120
+ });
21
121
  });
22
- window.WorkspaceEnvironment = { toggleBusy, updateStatus };
23
- toggleBusy(document.getElementById('workspace').dataset.busy === 'true');
122
+
123
+ window.WorkspaceEnvironment = {
124
+ toggleBusy,
125
+ updateStatus,
126
+ setStatus,
127
+ syncFromSnapshot(snapshot) {
128
+ if (!snapshot || presetButtons.length === 0 || !editor) return;
129
+ const rendered = snapshot.editable_environment;
130
+ if (!rendered || typeof rendered !== 'object') return;
131
+ const expected = Object.keys(rendered)
132
+ .sort()
133
+ .map((name) => name + '=' + rendered[name])
134
+ .join('\n');
135
+ if (editor === document.activeElement) return;
136
+ if (editor.value !== expected) return;
137
+ updatePresetButtons(matchingPresetNameForDraft(expected));
138
+ }
139
+ };
140
+ updatePresetButtons(matchingPresetNameForDraft(initialDraft));
141
+ if (!hasPresets) {
142
+ presetButtons.forEach((button) => {
143
+ button.disabled = true;
144
+ });
145
+ }
146
+ toggleBusy(activeBusy);
24
147
  })();
@@ -270,6 +270,47 @@ fieldset:disabled {
270
270
  align-items: start;
271
271
  }
272
272
 
273
+ .controls-panel h2,
274
+ .controls-panel h3 {
275
+ margin: 0;
276
+ }
277
+
278
+ .workspace-actions-group,
279
+ .workspace-settings {
280
+ display: grid;
281
+ gap: var(--space-3);
282
+ }
283
+
284
+ .workspace-settings {
285
+ border-top: 1px solid var(--border);
286
+ padding-top: var(--space-4);
287
+ }
288
+
289
+ .workspace-actions {
290
+ margin-block-start: 0;
291
+ }
292
+
293
+ .workspace-actions .button,
294
+ .workspace-actions form {
295
+ margin: 0;
296
+ }
297
+
298
+ .env-preset-buttons {
299
+ display: flex;
300
+ flex-wrap: wrap;
301
+ gap: var(--space-2);
302
+ }
303
+
304
+ .env-preset-button {
305
+ min-block-size: 34px;
306
+ padding-inline: var(--space-3);
307
+ }
308
+
309
+ .env-preset-button[aria-pressed='true'] {
310
+ border-color: color-mix(in oklab, var(--success) 55%, var(--border));
311
+ background: color-mix(in oklab, var(--success) 22%, transparent);
312
+ }
313
+
273
314
  .button {
274
315
  display: inline-flex;
275
316
  justify-content: center;
@@ -17,12 +17,44 @@
17
17
  <section class="error-panel setup-error" id="error-panel" role="alert" <%= 'hidden' unless @presentation['error'] %>>
18
18
  <h3>What went wrong</h3><pre id="error"><%= escape(@presentation['error']) %></pre>
19
19
  </section>
20
- <div class="workspace-actions">
21
- <a id="preview" class="button button--primary" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
22
- <div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
23
- </div>
24
20
  <p id="connection" role="status"></p>
25
- <p id="source-update-message" role="status"><%= field('source_update_message') %></p>
21
+ </section>
22
+ <section class="status-panel controls-panel" aria-labelledby="actions-settings-title">
23
+ <h2 id="actions-settings-title">Actions &amp; settings</h2>
24
+ <div class="workspace-actions-group" aria-labelledby="actions-heading">
25
+ <h3 id="actions-heading">Workspace actions</h3>
26
+ <div class="workspace-actions">
27
+ <a id="preview" class="button button--primary" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
28
+ <div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
29
+ <a class="button button--subtle" href="/workspaces/<%= @id %><%= @snapshot['log_source'] == 'backend' ? '?log=backend' : '' %>">Refresh status</a>
30
+ </div>
31
+ <p id="source-update-message" role="status"><%= field('source_update_message') %></p>
32
+ </div>
33
+ <div class="workspace-settings" aria-labelledby="environment-overrides">
34
+ <h3 id="environment-overrides">Editable environment</h3>
35
+ <form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
36
+ <fieldset class="env-fieldset" data-environment-fieldset <%= 'disabled' if @presentation['busy'] %>>
37
+ <legend class="env-legend">Workspace settings</legend>
38
+ <% unless environment_presets.empty? %>
39
+ <div data-preset-controls hidden>
40
+ <p class="env-input-label" id="environment-preset-label">Preset</p>
41
+ <div class="env-preset-buttons" role="group" aria-labelledby="environment-preset-label" aria-describedby="environment-preset-help">
42
+ <% environment_presets.each_key do |name| %>
43
+ <button type="button" class="button button--subtle env-preset-button" data-environment-preset="<%= escape(name) %>" aria-pressed="false"><%= escape(name) %></button>
44
+ <% end %>
45
+ </div>
46
+ <p class="muted" id="environment-preset-help">Select a preset, then Save &amp; restart to apply.</p>
47
+ </div>
48
+ <% end %>
49
+ <label class="env-input-label" for="editable-env">Environment variables</label>
50
+ <textarea id="editable-env" name="editable_env" rows="8" spellcheck="false" autocomplete="off" autocapitalize="off"><%= escape(editable_environment_text) %></textarea>
51
+ </fieldset>
52
+ <div class="env-actions">
53
+ <button class="button button--primary" data-env-submit <%= 'disabled' if @presentation['busy'] %>>Save &amp; restart</button>
54
+ </div>
55
+ <p class="muted" id="environment-status" role="status" aria-live="polite"></p>
56
+ </form>
57
+ </div>
26
58
  </section>
27
59
  <section class="log-panel" aria-labelledby="log-title">
28
60
  <div class="log-header"><h2 id="log-title">Live <%= field('log_source') == 'backend' ? 'backend' : 'setup' %> log</h2><time id="updated"><%= field('updated_at') %></time></div>
@@ -32,23 +64,6 @@
32
64
  </nav>
33
65
  <pre id="log" tabindex="0" aria-label="<%= field('log_source') == 'backend' ? 'Backend' : 'Setup' %> log"><%= field('log_tail') %></pre>
34
66
  </section>
35
- <section class="status-panel env-panel" aria-labelledby="environment-overrides">
36
- <h2 id="environment-overrides">Editable environment</h2>
37
- <p class="muted">One NAME=value per line. These non-secret settings start from <code>default_editable_env</code> in the trusted recipe. Edit values to customize this workspace; remove a line to restore its default.</p>
38
- <p class="muted">Do not enter secrets: editor values appear in the dashboard and status API. Add other non-secret names as needed. Previously saved values outside the editor remain hidden and unchanged.</p>
39
- <form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" data-environment-form>
40
- <fieldset class="env-fieldset" data-environment-fieldset>
41
- <legend class="env-legend">Workspace settings</legend>
42
- <label class="env-input-label" for="editable-env">Environment variables</label>
43
- <textarea id="editable-env" name="editable_env" rows="8" spellcheck="false" autocomplete="off" autocapitalize="off" aria-describedby="editable-env-help"><%= escape(editable_environment_text) %></textarea>
44
- <p class="muted" id="editable-env-help">Values may contain =. Blank lines are ignored. Names are case-sensitive and cannot repeat.</p>
45
- </fieldset>
46
- <div class="env-actions">
47
- <button class="button button--primary" data-env-submit>Save &amp; restart</button>
48
- </div>
49
- <p class="muted" id="environment-status" role="status" aria-live="polite"></p>
50
- </form>
51
- </section>
52
67
  <details class="status-panel"><summary>Workspace details</summary><dl><dt>Preview URL</dt><dd><a class="detail-link" href="<%= escape(Config.preview_url(@id)) %>" target="_blank" rel="noopener noreferrer"><%= escape(Config.preview_url(@id)) %></a> <span class="muted">Available when ready</span></dd><dt>Branch</dt><dd><%= field('branch') %></dd><dt>Path</dt><dd><code><%= field('path') %></code></dd><dt>Related PR</dt><dd><%= escape(@snapshot.dig('related_pr', 'number') || 'None') %></dd><dt>Created</dt><dd><%= escape(@snapshot['created_at'] || 'Unavailable') %></dd><dt>Last startup</dt><dd><%= escape(@snapshot['started_at'] || 'Never') %></dd></dl></details>
53
68
  <section class="status-panel danger-zone" aria-labelledby="danger-heading">
54
69
  <h2 id="danger-heading">Danger Zone</h2>
@@ -1,4 +1,5 @@
1
1
  require 'erb'
2
+ require 'json'
2
3
  require_relative 'config'
3
4
  require_relative 'presentation'
4
5
 
@@ -123,6 +124,23 @@ module Workspaces
123
124
  values.sort.map { |name, value| "#{name}=#{value}" }.join("\n")
124
125
  end
125
126
 
127
+ def environment_presets
128
+ presets = @snapshot['environment_presets']
129
+ return {} unless presets.is_a?(Hash)
130
+
131
+ presets.each_with_object({}) do |(name, entries), memo|
132
+ next unless name.respond_to?(:to_s)
133
+ next unless entries.is_a?(Hash)
134
+
135
+ lines = entries.sort.map { |entry_name, value| "#{entry_name}=#{value}" }.join("\n")
136
+ memo[name.to_s] = lines
137
+ end
138
+ end
139
+
140
+ def environment_presets_json
141
+ JSON.generate(environment_presets)
142
+ end
143
+
126
144
  def action_form(action)
127
145
  style = action['primary'] ? 'button--primary' : 'button--subtle'
128
146
  "<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
@@ -75,6 +75,33 @@ module Workspaces
75
75
  load
76
76
  end
77
77
 
78
+ def apply_preset(name, recipe: Recipe.new)
79
+ apply_settings(preset: name, recipe: recipe)
80
+ end
81
+
82
+ def self.settings_for(recipe:, preset: nil, set: {}, unset: [], current: nil)
83
+ defaults = recipe.default_editable_env
84
+ preset_values = recipe.environment_presets.fetch(preset) { raise Invalid, "Unknown environment preset: #{preset}" } if preset
85
+ values = preset ? defaults.merge(preset_values) : (current || defaults).dup
86
+ validate_entries!(set)
87
+ unset.each { |name| validate_name!(name) }
88
+ raise Invalid, 'Cannot set and unset the same name' unless (set.keys & unset).empty?
89
+ names = set.keys + unset
90
+ if names.any? { |name| name.match?(/(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i) }
91
+ raise Invalid, 'Secret-like environment names cannot be stored in the visible editor'
92
+ end
93
+ values.merge!(set)
94
+ unset.each { |name| values.delete(name) }
95
+ validate_entries!(values)
96
+ values
97
+ end
98
+
99
+ def apply_settings(preset: nil, set: {}, unset: [], recipe: Recipe.new)
100
+ current = preset ? nil : editable_values(recipe.default_editable_env)
101
+ values = self.class.settings_for(recipe: recipe, preset: preset, set: set, unset: unset, current: current)
102
+ replace_editable(values.sort.map { |key, value| "#{key}=#{value}" }.join("\n"), defaults: recipe.default_editable_env)
103
+ end
104
+
78
105
  def apply_patch(set:, remove:)
79
106
  validate_patch!(set, remove)
80
107
  updated = load_file(file_path).merge(set)
@@ -7,13 +7,14 @@ module Workspaces
7
7
  class Invalid < ArgumentError; end
8
8
  RESERVED_ENV = %w(WORKSPACE_ID WORKSPACE_PATH WORKSPACE_REPO_ROOT WORKSPACE_PORT WORKSPACE_URL
9
9
  WORKSPACE_HOST WORKSPACE_CACHE_DIR WORKSPACE_RUN_ID).freeze
10
- attr_reader :steps, :environment, :default_editable_env
10
+ attr_reader :steps, :environment, :default_editable_env, :environment_presets
11
11
 
12
12
  def initialize(root: Config.repo_root)
13
13
  @root = root
14
14
  document = YAML.safe_load(root.join('.workspaces.yml').read, aliases: false)
15
15
  validate_document!(document)
16
16
  @default_editable_env = validate_editable_env!(document.fetch('default_editable_env', {}))
17
+ @environment_presets = validate_environment_presets!(document.fetch('environment_presets', {}))
17
18
  @environment = validate_env!(document.fetch('env', {}))
18
19
  @steps = document.fetch('steps').map { |step| validate_step!(step) }
19
20
  validate_editable_overlap!
@@ -49,7 +50,7 @@ module Workspaces
49
50
  raise Invalid,
50
51
  'Configuration must be a mapping with version: 1'
51
52
  end
52
- unknown_keys!(document, %w(version env default_editable_env steps))
53
+ unknown_keys!(document, %w(version env default_editable_env environment_presets steps))
53
54
  raise Invalid, 'steps must be a nonempty array' unless document['steps'].is_a?(Array) && !document['steps'].empty?
54
55
  end
55
56
 
@@ -109,14 +110,14 @@ module Workspaces
109
110
  values
110
111
  end
111
112
 
112
- def validate_editable_env!(values)
113
- raise Invalid, 'default_editable_env must be a mapping' unless values.is_a?(Hash)
113
+ def validate_editable_env!(values, setting: 'default_editable_env')
114
+ raise Invalid, "#{setting} must be a mapping" unless values.is_a?(Hash)
114
115
 
115
116
  EnvironmentOverrides.validate_entries!(values)
116
117
  values.each do |key, value|
117
118
  raise Invalid, "#{key} is supplied by the workspace runner" if RESERVED_ENV.include?(key)
118
119
  if key.match?(/(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i)
119
- raise Invalid, 'Secret-like names cannot be declared in default_editable_env'
120
+ raise Invalid, "Secret-like names cannot be declared in #{setting}"
120
121
  end
121
122
  raise Invalid, 'Editable environment values must be single-line strings' if value.match?(/[\r\n]/)
122
123
  end
@@ -125,6 +126,21 @@ module Workspaces
125
126
  raise Invalid, e.message
126
127
  end
127
128
 
129
+ def validate_environment_presets!(presets)
130
+ raise Invalid, 'environment_presets must be a mapping' unless presets.is_a?(Hash)
131
+
132
+ presets.each do |name, values|
133
+ unless name.is_a?(String) && !name.empty? && name == name.strip && !name.match?(/[[:cntrl:]]/)
134
+ raise Invalid, 'Preset names must be nonempty trimmed strings without control characters'
135
+ end
136
+ validate_editable_env!(values, setting: 'Each environment preset')
137
+ unless (values.keys - default_editable_env.keys).empty?
138
+ raise Invalid, 'Preset environment names must be declared in default_editable_env'
139
+ end
140
+ end
141
+ presets
142
+ end
143
+
128
144
  def validate_editable_overlap!
129
145
  names = default_editable_env.keys
130
146
  return unless environment.keys.intersect?(names) || steps.any? { |step| step['env'].keys.intersect?(names) }
@@ -1,6 +1,8 @@
1
1
  require_relative 'worktree'
2
2
  require_relative 'state_store'
3
3
  require_relative 'backend'
4
+ require_relative 'recipe'
5
+ require_relative 'environment_overrides'
4
6
 
5
7
  module Workspaces
6
8
  class Registry
@@ -17,9 +19,12 @@ module Workspaces
17
19
  end
18
20
  end
19
21
 
20
- def create(**options)
22
+ def create(preset: nil, set: {}, unset: [], **options)
23
+ recipe = Recipe.new if preset || !set.empty? || !unset.empty?
24
+ EnvironmentOverrides.settings_for(recipe: recipe, preset: preset, set: set, unset: unset) if recipe
21
25
  workspace = Worktree.create(**options)
22
26
  StateStore.new(Config.state_file).set(workspace.id, 'status' => 'idle', 'related_pr' => workspace.related_pr)
27
+ EnvironmentOverrides.new(workspace.id).apply_settings(preset: preset, set: set, unset: unset, recipe: recipe) if recipe
23
28
  workspace
24
29
  end
25
30
 
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.5'
2
+ VERSION = '0.1.7'
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: workspaces
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Workspaces contributors
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-02 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack