workspaces 0.1.5 → 0.1.6

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: 8817ac7615085c4aef7e23b75b8034ac494ad7c2c61b9f53dd14fc79232350cd
4
+ data.tar.gz: f670f3dbe8ad41cbcc46e29833bd0be27dcd04fcbe48465a78e80a756fc95764
5
5
  SHA512:
6
- metadata.gz: 173beba79ca2237d5632e06f7abb7edbc0a46f3a17d283bfdead8e59469bb9383d1de2af97f90c8c9c51e24d2b014aa6a21c63dc83f80ac257c488121f7ae909
7
- data.tar.gz: b98959ea514d65b64d454f928330b86959e417bbf1be83466790ea072cd350757b9daa5ff5c7724877684bc834618eeb6a6d6b20788879dcceba48ca8be32712
6
+ metadata.gz: e672a1e9ca5e3186364077718a1ff69851435de0a69e0deadcdeae1ab5715cc8a5acf4e42b17bc8f2639f58a6d6dcb907d10380cda72b59bbebf29d536846de8
7
+ data.tar.gz: dac093c4058c50a6a8abb863bf61156a88f760768ea5709995ffc800d4654b380305fffc09d3bb6466d19f4ca1ac9a0a85f47a09043c3f9902a66e40012db8b3
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,30 @@ 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
+
100
124
  The front door is not an authentication boundary. Its host and same-origin checks
101
125
  protect routing and POST actions, but a publicly reachable instance still needs
102
126
  access control such as VPN or SSO. Do not expose workspace backend ports directly.
@@ -93,7 +93,8 @@ module Workspaces
93
93
  'steps' => entry.fetch('steps', []),
94
94
  'log_tail' => include_log ? log_tail(id, log) : '', 'log_source' => log, 'port' => entry['port'],
95
95
  'environment_keys' => environment_keys(id),
96
- 'editable_environment' => editable_environment(id)
96
+ 'editable_environment' => editable_environment(id),
97
+ 'environment_presets' => environment_presets
97
98
  }
98
99
  end
99
100
 
@@ -220,6 +221,13 @@ module Workspaces
220
221
  {}
221
222
  end
222
223
 
224
+ def environment_presets
225
+ recipe = Recipe.new
226
+ recipe.environment_presets.transform_values { |overlay| recipe.default_editable_env.merge(overlay) }
227
+ rescue Recipe::Invalid
228
+ {}
229
+ end
230
+
223
231
  def update(id, attributes)
224
232
  @state.set(id, attributes.merge('updated_at' => now))
225
233
  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')}\">" \
@@ -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,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.5'
2
+ VERSION = '0.1.6'
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.6
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-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack