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 +4 -4
- data/DESIGN.md +2 -0
- data/README.md +24 -0
- data/lib/workspaces/coordinator.rb +9 -1
- data/lib/workspaces/dashboard/app.js +12 -7
- data/lib/workspaces/dashboard/environment.js +130 -7
- data/lib/workspaces/dashboard/styles.css +41 -0
- data/lib/workspaces/dashboard/template.html.erb +37 -22
- data/lib/workspaces/dashboard_view.rb +18 -0
- data/lib/workspaces/recipe.rb +21 -5
- data/lib/workspaces/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8817ac7615085c4aef7e23b75b8034ac494ad7c2c61b9f53dd14fc79232350cd
|
|
4
|
+
data.tar.gz: f670f3dbe8ad41cbcc46e29833bd0be27dcd04fcbe48465a78e80a756fc95764
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
18
|
+
button.disabled = busy;
|
|
18
19
|
});
|
|
19
20
|
if (window.WorkspaceEnvironment)
|
|
20
|
-
window.WorkspaceEnvironment.toggleBusy(
|
|
21
|
-
node('remove-form')
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
21
|
+
</section>
|
|
22
|
+
<section class="status-panel controls-panel" aria-labelledby="actions-settings-title">
|
|
23
|
+
<h2 id="actions-settings-title">Actions & 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 & 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 & 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 & 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')}\">" \
|
data/lib/workspaces/recipe.rb
CHANGED
|
@@ -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,
|
|
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,
|
|
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) }
|
data/lib/workspaces/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-10-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|