@needle-tools/worker-launcher 0.0.0-stage → 1.0.0-alpha.9
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.
- package/README.md +18 -3
- package/index.js +3 -0
- package/package.json +32 -6
- package/public/app.css +44 -0
- package/public/app.js +124 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +10 -0
- package/public/index.html +79 -0
- package/public/needle-logo.svg +59 -0
- package/public/workspace-controls.css +159 -0
- package/public/workspace-tokens.css +113 -0
- package/scripts/prepare-ui.mjs +9 -0
- package/src/autostart.js +52 -0
- package/src/cli.js +29 -0
- package/src/credentials.js +60 -0
- package/src/docker-client.js +16 -0
- package/src/docker-readiness.js +40 -0
- package/src/fleet.js +28 -0
- package/src/manager.js +112 -0
- package/src/registry-access.js +80 -0
- package/src/registry-metadata.js +14 -0
- package/src/registry-pull.js +29 -0
- package/src/server.js +47 -0
- package/src/supervisor.js +21 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/* The same form/control language applies in the page, its sidebars and dialogs. */
|
|
2
|
+
:is(.workspace-edit-controls, .workspace-controls) {
|
|
3
|
+
color: var(--ui-text);
|
|
4
|
+
font: var(--ui-font-size)/var(--ui-line-height) var(--ui-font-family);
|
|
5
|
+
min-width: 0;
|
|
6
|
+
}
|
|
7
|
+
:is(.workspace-edit-controls, .workspace-controls)
|
|
8
|
+
:is(button, .button):not(.view-toggle *):not(.workspace-datalist *):not(.select-option):not(.copy):not(.activity-day):not(.tag-editor):not(.chat-sources-toggle):not(.table-disclosure):not(.preview-poster):not(.dropdown-button):not(.linklike):not(.sidebar-iconbtn):not(.close-btn):not(.upload-preview-action):not(.image-edit):not(.workspace-label):not(.library-upload):not(.ui-button-inline):not(.ui-button-primary):not(.ui-button-warning):not(.ui-button-danger):not([popover]:not(.workspace-controls) *) {
|
|
9
|
+
font: inherit;
|
|
10
|
+
color: var(--ui-text);
|
|
11
|
+
min-height: var(--ui-control-height);
|
|
12
|
+
padding: var(--ui-control-padding-block) var(--ui-control-padding-inline);
|
|
13
|
+
border: 1px solid var(--ui-border);
|
|
14
|
+
border-radius: var(--ui-border-radius);
|
|
15
|
+
background: transparent;
|
|
16
|
+
box-shadow: none;
|
|
17
|
+
outline: var(--ui-control-outline, none);
|
|
18
|
+
}
|
|
19
|
+
:is(.workspace-edit-controls, .workspace-controls) :is(button, .button):not(.workspace-datalist *):not(.select-option):not(.copy):not(.image-edit):not(.activity-day):not(.tag-editor):not(.chat-sources-toggle):not(.linklike):not(.ui-button-danger):not(.ui-button-primary):not(.ui-button-warning):hover:not(:disabled) {
|
|
20
|
+
background: var(--ui-background-hover);
|
|
21
|
+
box-shadow: none;
|
|
22
|
+
outline: none;
|
|
23
|
+
}
|
|
24
|
+
:is(.workspace-edit-controls, .workspace-controls) :is(button, a):focus-visible {
|
|
25
|
+
--ui-control-outline: 1px solid var(--ui-border-focus);
|
|
26
|
+
outline: 1px solid var(--ui-border-focus);
|
|
27
|
+
outline-offset: 2px;
|
|
28
|
+
box-shadow: none;
|
|
29
|
+
}
|
|
30
|
+
:is(.workspace-edit-controls, .workspace-controls)
|
|
31
|
+
:is(
|
|
32
|
+
input:not([type]),
|
|
33
|
+
input[type="text"],
|
|
34
|
+
input[type="search"],
|
|
35
|
+
input[type="email"],
|
|
36
|
+
input[type="tel"],
|
|
37
|
+
input[type="password"],
|
|
38
|
+
input[type="url"],
|
|
39
|
+
input[type="number"],
|
|
40
|
+
textarea,
|
|
41
|
+
select
|
|
42
|
+
) {
|
|
43
|
+
box-sizing: border-box;
|
|
44
|
+
max-width: 100%;
|
|
45
|
+
font: inherit;
|
|
46
|
+
color: var(--ui-input-text);
|
|
47
|
+
padding: var(--ui-control-padding-block) var(--ui-control-padding-inline);
|
|
48
|
+
border: 1px solid var(--ui-input-border);
|
|
49
|
+
border-radius: var(--ui-border-radius);
|
|
50
|
+
background: var(--ui-input-background);
|
|
51
|
+
box-shadow: none;
|
|
52
|
+
outline: none;
|
|
53
|
+
min-height: var(--ui-control-height);
|
|
54
|
+
}
|
|
55
|
+
:is(.workspace-edit-controls, .workspace-controls) :is(input, textarea, select):focus-visible {
|
|
56
|
+
border-color: var(--ui-input-focus-border);
|
|
57
|
+
outline: none;
|
|
58
|
+
box-shadow: none;
|
|
59
|
+
}
|
|
60
|
+
:is(.workspace-edit-controls, .workspace-controls) input[type="checkbox"] {
|
|
61
|
+
accent-color: var(--ui-primary);
|
|
62
|
+
border-color: var(--ui-border);
|
|
63
|
+
border-radius: var(--ui-radius-sm);
|
|
64
|
+
box-shadow: none;
|
|
65
|
+
}
|
|
66
|
+
:is(.workspace-edit-controls, .workspace-controls) :is(h1, h2, h3) {
|
|
67
|
+
color: var(--ui-text);
|
|
68
|
+
font-family: var(--ui-font-family);
|
|
69
|
+
font-weight: var(--ui-font-weight-bold);
|
|
70
|
+
line-height: var(--ui-line-height);
|
|
71
|
+
}
|
|
72
|
+
:is(.workspace-edit-controls, .workspace-controls) h1 {
|
|
73
|
+
font-size: var(--ui-font-size-h1);
|
|
74
|
+
}
|
|
75
|
+
:is(.workspace-edit-controls, .workspace-controls) :is(h2, h3) {
|
|
76
|
+
font-size: var(--ui-font-size-lg);
|
|
77
|
+
}
|
|
78
|
+
:is(.workspace-edit-controls, .workspace-controls) .header {
|
|
79
|
+
margin-top: 0;
|
|
80
|
+
}
|
|
81
|
+
:is(.workspace-edit-controls, .workspace-controls) .page-title {
|
|
82
|
+
padding: 0;
|
|
83
|
+
background: transparent;
|
|
84
|
+
border: 0;
|
|
85
|
+
box-shadow: none;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* Inline actions inherit the surrounding text size instead of a full control height. */
|
|
89
|
+
:is(.workspace-controls,.workspace-edit-controls,.workspace-sidebar.sidebar,.dashboard-workspace) button.ui-button-inline {
|
|
90
|
+
display:inline-flex;align-items:center;vertical-align:baseline;
|
|
91
|
+
min-height:0;height:auto;padding:0 .4em;margin-block:0;
|
|
92
|
+
font:inherit;line-height:inherit;color:var(--ui-text);
|
|
93
|
+
border:1px solid var(--ui-border-hover);border-radius:var(--ui-radius-md);
|
|
94
|
+
background:var(--ui-background-elevated);box-shadow:none;outline:none;
|
|
95
|
+
}
|
|
96
|
+
:is(.workspace-controls,.workspace-edit-controls,.workspace-sidebar.sidebar,.dashboard-workspace) button.ui-button-inline:hover:not(:disabled) {background:var(--ui-background-hover);}
|
|
97
|
+
:is(.workspace-controls,.workspace-edit-controls,.workspace-sidebar.sidebar,.dashboard-workspace) button.ui-button-inline:focus-visible {outline:1px solid var(--ui-border-focus);outline-offset:1px;}
|
|
98
|
+
|
|
99
|
+
:is(.workspace-controls,.workspace-edit-controls,.workspace-sidebar,.dashboard-workspace) :is(button,.button):is(:disabled,[aria-disabled="true"]) {opacity:.45;cursor:not-allowed;box-shadow:none;}
|
|
100
|
+
:is(.workspace-controls,.workspace-edit-controls) button.ui-button-warning {font:inherit;color:var(--ui-warning);background:transparent;border:1px solid var(--ui-warning);border-radius:var(--ui-border-radius);padding:var(--ui-control-padding-block) var(--ui-control-padding-inline);min-height:var(--ui-control-height);box-shadow:none;outline:none;}
|
|
101
|
+
:is(.workspace-controls,.workspace-edit-controls) button.ui-button-warning:hover:not(:disabled) {background:color-mix(in srgb,var(--ui-warning) 10%,transparent);}
|
|
102
|
+
|
|
103
|
+
:is(.workspace-edit-controls, .workspace-controls)
|
|
104
|
+
:is(button, .button):not(.workspace-datalist *):not(.select-option):not(.copy):not(.activity-day):not(.table-disclosure):not(.preview-poster):not(.dropdown-button):not(.linklike):not(.sidebar-iconbtn):not(.close-btn):not(.upload-preview-action):not(.image-edit):not(.workspace-label):not(.library-upload):not(.ui-button-inline):not(.ui-button-primary):not(.ui-button-warning):not(.ui-button-danger):not([popover]:not(.workspace-controls) *):hover:not(:disabled):not([aria-disabled="true"]) {
|
|
105
|
+
border-color: var(--ui-control-hover-border);
|
|
106
|
+
background: var(--ui-background-hover);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
:is(.workspace-controls,.workspace-edit-controls) button.ui-button-danger {display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;min-height:var(--ui-control-height);padding:var(--ui-control-padding-block) var(--ui-control-padding-inline);border-radius:var(--ui-border-radius);margin:0;font:inherit;color:var(--ui-error,var(--c-error));background:transparent;border:1px solid var(--ui-error,var(--c-error));box-shadow:none;outline:none;}
|
|
110
|
+
:is(.workspace-controls,.workspace-edit-controls) button.ui-button-danger:hover:not(:disabled) {color:var(--ui-error,var(--c-error));background:color-mix(in srgb,var(--ui-error,var(--c-error)) 12%,var(--ui-background));border-color:var(--ui-error,var(--c-error));}
|
|
111
|
+
|
|
112
|
+
/* Destructive menu actions share the menu surface, without nested button chrome. */
|
|
113
|
+
.workspace-controls [popover] button.show_cancel_dialogue.ui-button-danger {border:0;box-shadow:none;background:transparent;justify-content:flex-start;width:100%;}
|
|
114
|
+
|
|
115
|
+
/* Keep the native, accessible file chooser while matching workspace controls. */
|
|
116
|
+
:is(.workspace-controls, .workspace-edit-controls, .public-workspace.public-form) input[type="file"] {
|
|
117
|
+
max-width: 100%;
|
|
118
|
+
min-width: 0;
|
|
119
|
+
padding: 0;
|
|
120
|
+
border: 0;
|
|
121
|
+
background: transparent;
|
|
122
|
+
color: var(--ui-text-secondary);
|
|
123
|
+
font: inherit;
|
|
124
|
+
}
|
|
125
|
+
:is(.workspace-controls, .workspace-edit-controls, .public-workspace.public-form) input[type="file"]::file-selector-button {
|
|
126
|
+
min-height: var(--ui-control-height);
|
|
127
|
+
margin-inline-end: var(--ui-spacing-md);
|
|
128
|
+
padding: var(--ui-control-padding-block) var(--ui-control-padding-inline);
|
|
129
|
+
border: 1px solid var(--ui-border);
|
|
130
|
+
border-radius: var(--ui-border-radius);
|
|
131
|
+
background: transparent;
|
|
132
|
+
color: var(--ui-text);
|
|
133
|
+
font: inherit;
|
|
134
|
+
cursor: pointer;
|
|
135
|
+
}
|
|
136
|
+
:is(.workspace-controls, .workspace-edit-controls, .public-workspace.public-form) input[type="file"]::file-selector-button:hover {
|
|
137
|
+
background: var(--ui-background-hover);
|
|
138
|
+
}
|
|
139
|
+
:is(.workspace-controls, .workspace-edit-controls, .public-workspace.public-form) input[type="file"]:focus-visible {
|
|
140
|
+
outline: 1px solid var(--ui-border-focus);
|
|
141
|
+
outline-offset: 3px;
|
|
142
|
+
border-radius: var(--ui-border-radius);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Primary actions shared by editor headers and dialogs. */
|
|
146
|
+
:is(.workspace-controls,.workspace-edit-controls,.workspace-sidebar.sidebar,.dashboard-workspace) button.ui-button-primary {display:inline-flex;align-items:center;justify-content:center;gap:var(--ui-spacing-sm);min-height:var(--ui-control-height);padding:var(--ui-control-padding-block) var(--ui-control-padding-inline);margin:0;border:1px solid var(--ui-button-border,var(--ui-button-background,var(--ui-primary)));border-radius:var(--ui-border-radius);background:var(--ui-button-background,var(--ui-primary));color:var(--ui-text);font:inherit;box-shadow:none;outline:none;cursor:pointer;transition:background-color var(--ui-motion-fast),border-color var(--ui-motion-fast);}
|
|
147
|
+
:is(.workspace-controls,.workspace-edit-controls,.workspace-sidebar.sidebar,.dashboard-workspace) button.ui-button-primary:hover:not(:disabled) {background:var(--ui-button-background-hover,color-mix(in srgb,var(--ui-button-background,var(--ui-primary)) 82%,var(--ui-text)));border-color:var(--ui-button-border-hover,var(--ui-button-background-hover,color-mix(in srgb,var(--ui-button-background,var(--ui-primary)) 82%,var(--ui-text))));}
|
|
148
|
+
:is(.workspace-controls,.workspace-edit-controls,.workspace-sidebar.sidebar,.dashboard-workspace) button.ui-button-primary:active:not(:disabled) {background:color-mix(in srgb,var(--ui-button-background,var(--ui-primary)) 68%,var(--ui-text));}
|
|
149
|
+
:is(.workspace-controls,.workspace-edit-controls,.workspace-sidebar.sidebar,.dashboard-workspace) button.ui-button-primary:focus-visible {outline:1px solid var(--ui-border-focus);outline-offset:2px;}
|
|
150
|
+
|
|
151
|
+
button.ui-button-save { --ui-button-border:var(--ui-save-border); --ui-button-border-hover:var(--ui-save-border-hover); --ui-button-background: var(--ui-save-background); --ui-button-background-hover: var(--ui-save-background-hover); }
|
|
152
|
+
|
|
153
|
+
:is(.workspace-controls,.workspace-edit-controls) button.ui-button-danger.ui-button-danger-subtle{color:var(--ui-error);background:var(--ui-danger-background);border-color:var(--ui-danger-border);}
|
|
154
|
+
:is(.workspace-controls,.workspace-edit-controls) button.ui-button-danger.ui-button-danger-subtle:hover:not(:disabled){color:var(--ui-error);background:var(--ui-danger-background-hover);border-color:var(--ui-error);}
|
|
155
|
+
|
|
156
|
+
/* Text fields must override the legacy global input:hover shadow and outline. */
|
|
157
|
+
:is(.workspace-controls,.workspace-edit-controls) :is(input:not([type]),input[type="text"],input[type="search"],input[type="email"],input[type="tel"],input[type="password"],input[type="url"],input[type="number"],textarea,select):is(:hover,:focus,:focus-visible){outline:none;box-shadow:none;color:var(--ui-input-text);}
|
|
158
|
+
:is(.workspace-controls,.workspace-edit-controls) :is(input:not([type]),input[type="text"],input[type="search"],input[type="email"],input[type="tel"],input[type="password"],input[type="url"],input[type="number"],textarea,select):hover:not(:focus){border-color:var(--ui-border-hover);}
|
|
159
|
+
:is(.workspace-controls,.workspace-edit-controls) :is(input:not([type]),input[type="text"],input[type="search"],input[type="email"],input[type="tel"],input[type="password"],input[type="url"],input[type="number"],textarea,select):focus{border-color:var(--ui-input-focus-border);}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/* Cloud workspace theme. Semantic names follow needle-web-editor core.ui.
|
|
2
|
+
* Override bases on a workspace root; all derived values resolve there.
|
|
3
|
+
* Intentionally opt-in: classic pages and embedded viewers retain their themes. */
|
|
4
|
+
:root {
|
|
5
|
+
--ui-job-status-running: #24c5b0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
body:has(.workspace-page),
|
|
9
|
+
.settings-workspace,
|
|
10
|
+
.public-workspace,
|
|
11
|
+
.workspace-notifications,
|
|
12
|
+
.workspace-page,
|
|
13
|
+
.workspace-header,
|
|
14
|
+
.workspace-edit,
|
|
15
|
+
.dashboard-workspace,
|
|
16
|
+
.workspace-appbar,
|
|
17
|
+
.workspace-sidebar {
|
|
18
|
+
/* Bases: Cloud keeps its own density and subdued focus treatment. */
|
|
19
|
+
--ui-primary: #99d46b;
|
|
20
|
+
--ui-background: #f0f4ef;
|
|
21
|
+
--ui-font-family: system-ui, -apple-system, sans-serif;
|
|
22
|
+
--ui-font-size: 13px;
|
|
23
|
+
--ui-spacing-unit: 4px;
|
|
24
|
+
--ui-border-radius: 6px;
|
|
25
|
+
--ui-motion-fast: 120ms;
|
|
26
|
+
--ui-chart-track: color-mix(in srgb, var(--ui-text) 10%, var(--ui-background));
|
|
27
|
+
--ui-chart-primary: var(--ui-primary);
|
|
28
|
+
--ui-chart-secondary: #5d9edb;
|
|
29
|
+
--ui-chart-tertiary: #e9b54a;
|
|
30
|
+
--ui-tag-font-size: 11px;
|
|
31
|
+
--ui-tag-radius: 999px;
|
|
32
|
+
--ui-tag-padding: 2px 7px;
|
|
33
|
+
--ui-content-max-width: 1520px;
|
|
34
|
+
|
|
35
|
+
--ui-loading-color: oklch(from var(--ui-primary) calc(l - 0.08) calc(c + 0.03) h);
|
|
36
|
+
--ui-loading-outline-color: oklch(from var(--ui-background) calc(l - 0.08) c h);
|
|
37
|
+
--ui-text: oklch(from var(--ui-background) calc(1 - l + .1) c h);
|
|
38
|
+
--ui-text-secondary: color-mix(in srgb, var(--ui-text) 70%, var(--ui-background));
|
|
39
|
+
--ui-text-muted: color-mix(in srgb, var(--ui-text) 50%, var(--ui-background));
|
|
40
|
+
--ui-border: color-mix(in srgb, var(--ui-text) 12%, var(--ui-background));
|
|
41
|
+
--ui-border-hover: color-mix(in srgb, var(--ui-text) 30%, var(--ui-background));
|
|
42
|
+
--ui-border-focus: color-mix(in srgb, var(--ui-primary) 35%, var(--ui-text-secondary));
|
|
43
|
+
--ui-background-alt: color-mix(in srgb, var(--ui-background) 90%, var(--ui-text-secondary));
|
|
44
|
+
--ui-background-hover: color-mix(in srgb, var(--ui-primary) 10%, transparent);
|
|
45
|
+
--ui-drop-border: var(--ui-border-focus);
|
|
46
|
+
--ui-drop-background: color-mix(in srgb, var(--ui-primary) 5%, var(--ui-background-semi-transparent));
|
|
47
|
+
--ui-drop-text: var(--ui-text);
|
|
48
|
+
--ui-control-hover-background: var(--ui-background-hover);
|
|
49
|
+
--ui-control-hover-border: var(--ui-border-hover);
|
|
50
|
+
--ui-nav-gap: var(--ui-spacing-md);
|
|
51
|
+
--ui-background-elevated: color-mix(in srgb, var(--ui-background) 95%, white);
|
|
52
|
+
--ui-background-semi-transparent: color-mix(in srgb, var(--ui-background) 96%, transparent);
|
|
53
|
+
--ui-menu-background: var(--ui-background);
|
|
54
|
+
--ui-menu-item-active-background: var(--ui-list-row-selected-background);
|
|
55
|
+
--ui-list-row-selected-background: color-mix(in srgb, var(--ui-primary) 18%, transparent);
|
|
56
|
+
--ui-input-background: var(--ui-background-elevated);
|
|
57
|
+
--ui-input-border: var(--ui-border);
|
|
58
|
+
--ui-input-text: var(--ui-text);
|
|
59
|
+
--ui-input-placeholder: var(--ui-text-secondary);
|
|
60
|
+
--ui-input-focus-border: var(--ui-border-focus);
|
|
61
|
+
--ui-error: #b64b43;
|
|
62
|
+
--ui-danger-background: color-mix(in srgb, var(--ui-error) 7%, var(--ui-background));
|
|
63
|
+
--ui-danger-background-hover: color-mix(in srgb, var(--ui-error) 16%, var(--ui-background));
|
|
64
|
+
--ui-danger-border: color-mix(in srgb, var(--ui-error) 45%, var(--ui-background));
|
|
65
|
+
--ui-danger-contrast: oklch(from var(--ui-error) calc(1 - l + .5) 0 h);
|
|
66
|
+
--ui-warning: #ae721b;
|
|
67
|
+
--ui-success: color-mix(in srgb, var(--ui-primary) 70%, var(--ui-text));
|
|
68
|
+
--ui-edit-preview-height: max(18rem, calc(100dvh - var(--ui-header-height)));
|
|
69
|
+
--ui-save-background: #e1eed7;
|
|
70
|
+
--ui-save-border: color-mix(in srgb, var(--ui-save-background) 82%, var(--ui-text-secondary));
|
|
71
|
+
--ui-save-border-hover: color-mix(in srgb, var(--ui-save-background-hover) 78%, var(--ui-text-secondary));
|
|
72
|
+
--ui-save-background-hover: oklch(from var(--ui-save-background) calc(l - 0.04) calc(c + .05) h);
|
|
73
|
+
|
|
74
|
+
--ui-font-size-xs: calc(var(--ui-font-size) - 2px);
|
|
75
|
+
--ui-font-size-sm: calc(var(--ui-font-size) - 1px);
|
|
76
|
+
--ui-font-size-md: var(--ui-font-size);
|
|
77
|
+
--ui-font-size-lg: calc(var(--ui-font-size) + 3px);
|
|
78
|
+
--ui-font-size-h1: calc(var(--ui-font-size) + 8px);
|
|
79
|
+
--ui-font-weight-normal: 400;
|
|
80
|
+
--ui-font-weight-medium: 500;
|
|
81
|
+
--ui-font-weight-bold: 600;
|
|
82
|
+
--ui-line-height: 1.4;
|
|
83
|
+
--ui-spacing-xs: calc(var(--ui-spacing-unit) / 2);
|
|
84
|
+
--ui-spacing-sm: var(--ui-spacing-unit);
|
|
85
|
+
--ui-spacing-md: calc(var(--ui-spacing-unit) * 2);
|
|
86
|
+
--ui-spacing-lg: calc(var(--ui-spacing-unit) * 3);
|
|
87
|
+
--ui-spacing-xl: calc(var(--ui-spacing-unit) * 4);
|
|
88
|
+
--ui-spacing-xxl: calc(var(--ui-spacing-unit) * 6);
|
|
89
|
+
--ui-radius-sm: calc(var(--ui-border-radius) / 2);
|
|
90
|
+
--ui-radius-md: var(--ui-border-radius);
|
|
91
|
+
--ui-radius-lg: calc(var(--ui-border-radius) * 1.5);
|
|
92
|
+
--ui-control-padding-block: var(--ui-spacing-sm);
|
|
93
|
+
--ui-control-padding-inline: var(--ui-spacing-md);
|
|
94
|
+
--ui-control-gap: var(--ui-spacing-md);
|
|
95
|
+
--ui-control-height: max(calc(var(--ui-spacing-unit) * 8), calc(var(--ui-font-size) * var(--ui-line-height) + var(--ui-spacing-md)));
|
|
96
|
+
--ui-control-height-sm: calc(var(--ui-control-height) - var(--ui-spacing-sm));
|
|
97
|
+
--ui-row-height: var(--ui-control-height);
|
|
98
|
+
--ui-icon-size: calc(var(--ui-font-size) + 3px);
|
|
99
|
+
--ui-icon-size-sm: calc(var(--ui-icon-size) * .75);
|
|
100
|
+
--ui-list-thumbnail: var(--ui-control-height);
|
|
101
|
+
--ui-header-height: calc(var(--ui-control-height) + var(--ui-spacing-xl));
|
|
102
|
+
--ui-gap: var(--ui-spacing-xl);
|
|
103
|
+
--ui-sidebar-gap: calc(var(--ui-spacing-unit) * 8);
|
|
104
|
+
--ui-sidebar-width: calc(var(--ui-font-size) * 14 + var(--ui-spacing-xs));
|
|
105
|
+
--ui-product-menu-width: calc(var(--ui-font-size) * 20);
|
|
106
|
+
--ui-preview-bottom-space: var(--ui-spacing-xl);
|
|
107
|
+
--ui-blur: blur(var(--ui-spacing-lg));
|
|
108
|
+
--ui-shadow: 0 var(--ui-spacing-xs) var(--ui-spacing-lg) color-mix(in srgb, var(--ui-text) 9%, transparent);
|
|
109
|
+
--ui-shadow-md: 0 var(--ui-spacing-sm) var(--ui-spacing-xxl) color-mix(in srgb, var(--ui-text) 12%, transparent);
|
|
110
|
+
--ui-card-hover-offset: calc(-1 * var(--ui-spacing-xs));
|
|
111
|
+
--ui-card-hover-shadow: var(--ui-shadow);
|
|
112
|
+
--ui-transition: var(--ui-motion-fast) ease;
|
|
113
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {copyFile,mkdir,access} from 'node:fs/promises';
|
|
2
|
+
const output=new URL('../public/',import.meta.url);
|
|
3
|
+
await mkdir(output,{recursive:true});
|
|
4
|
+
const assets=[...['workspace-tokens.css','workspace-controls.css'].map(name=>[name,'src/css/'+name]),['needle-logo.svg','static/logo_needle_black_no_padding.svg'],['favicon.ico','static/favicon.ico']];
|
|
5
|
+
for(const [name,path] of assets){
|
|
6
|
+
const source=new URL('../../needle-share-admin/'+path,import.meta.url);
|
|
7
|
+
try{await access(source);}catch{await access(new URL(name,output));continue;}
|
|
8
|
+
await copyFile(source,new URL(name,output));
|
|
9
|
+
}
|
package/src/autostart.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {access,mkdir,writeFile,unlink} from 'node:fs/promises';
|
|
2
|
+
import {execFile} from 'node:child_process';
|
|
3
|
+
import {promisify} from 'node:util';
|
|
4
|
+
import {createHash} from 'node:crypto';
|
|
5
|
+
import {homedir} from 'node:os';
|
|
6
|
+
import {dirname,resolve,posix,win32} from 'node:path';
|
|
7
|
+
import {fileURLToPath} from 'node:url';
|
|
8
|
+
|
|
9
|
+
const execute=promisify(execFile);
|
|
10
|
+
const xml=value=>value.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''');
|
|
11
|
+
const ps=value=>"'"+value.replaceAll("'","''")+"'";
|
|
12
|
+
const encoded=value=>Buffer.from(value,'utf16le').toString('base64');
|
|
13
|
+
const desktop=value=>'"'+value.replaceAll('\\','\\\\\\\\').replaceAll('"','\\\\"').replaceAll('$','\\\\$').replaceAll('`','\\\\`').replaceAll('%','%%')+'"';
|
|
14
|
+
|
|
15
|
+
/** Generate OS-owned login registration using only persistent executable paths, never keys. */
|
|
16
|
+
export function autostartPlan({platform=process.platform,home=homedir(),environment=process.env,directory,port,node=process.execPath,entry=fileURLToPath(new URL('../index.js',import.meta.url))}){
|
|
17
|
+
if(/[\\/]_npx[\\/]/.test(entry))throw new Error('Autostart requires a persistent launcher installation, not the temporary npx cache.');
|
|
18
|
+
const identity=createHash('sha256').update(resolve(directory)).digest('hex').slice(0,12);
|
|
19
|
+
const args=[entry,'start','--port',String(port),'--state-dir',resolve(directory)];
|
|
20
|
+
if([node,...args].some(value=>/[\r\n\0]/.test(value)))throw new Error('Launcher paths cannot contain newlines or null characters.');
|
|
21
|
+
if(platform==='win32'){
|
|
22
|
+
if(!environment.APPDATA||!environment.SystemRoot)throw new Error('Windows startup folder could not be resolved.');
|
|
23
|
+
const path=win32.join(environment.APPDATA,'Microsoft','Windows','Start Menu','Programs','Startup',`NeedleWorkerLauncher-${identity}.lnk`);
|
|
24
|
+
const powershell=win32.join(environment.SystemRoot,'System32','WindowsPowerShell','v1.0','powershell.exe');
|
|
25
|
+
const launch=`Start-Process -FilePath ${ps(node)} -ArgumentList ${ps(args.map(value=>'"'+value+'"').join(' '))} -WindowStyle Hidden`;
|
|
26
|
+
const argumentsText=`-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encoded(launch)}`;
|
|
27
|
+
const script=`$ErrorActionPreference='Stop'; $shell=New-Object -ComObject WScript.Shell; $link=$shell.CreateShortcut(${ps(path)}); $link.TargetPath=${ps(powershell)}; $link.Arguments=${ps(argumentsText)}; $link.WorkingDirectory=${ps(resolve(directory))}; $link.WindowStyle=7; $link.Save()`;
|
|
28
|
+
return {path,command:{file:powershell,args:['-NoProfile','-NonInteractive','-WindowStyle','Hidden','-EncodedCommand',encoded(script)]}};
|
|
29
|
+
}
|
|
30
|
+
if(platform==='darwin'){
|
|
31
|
+
const label=`tools.needle.worker-launcher.${identity}`;
|
|
32
|
+
return {path:posix.join(home,'Library','LaunchAgents',label+'.plist'),content:`<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${label}</string><key>ProgramArguments</key><array>${[node,...args].map(value=>`<string>${xml(value)}</string>`).join('')}</array><key>RunAtLoad</key><true/><key>ProcessType</key><string>Background</string><key>WorkingDirectory</key><string>${xml(resolve(directory))}</string></dict></plist>\n`};
|
|
33
|
+
}
|
|
34
|
+
if(platform==='linux')return {path:posix.join(environment.XDG_CONFIG_HOME||posix.join(home,'.config'),'autostart',`needle-worker-launcher-${identity}.desktop`),content:`[Desktop Entry]\nType=Application\nName=Needle Worker Launcher\nComment=Start the local worker launcher at sign-in\nExec=${[node,...args].map(desktop).join(' ')}\nTerminal=false\nX-GNOME-Autostart-enabled=true\n`};
|
|
35
|
+
throw new Error('Autostart is supported on Windows, macOS and Linux desktop sessions.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createAutostart(options){
|
|
39
|
+
let plan,unavailable;
|
|
40
|
+
try{plan=autostartPlan(options);}catch(error){unavailable=error.message;}
|
|
41
|
+
return {
|
|
42
|
+
async status(){if(!plan)return {supported:false,enabled:false,reason:unavailable};try{await access(plan.path);return {supported:true,enabled:true,path:plan.path};}catch(error){if(error.code!=='ENOENT')throw error;return {supported:true,enabled:false,path:plan.path};}},
|
|
43
|
+
async set(enabled){
|
|
44
|
+
if(typeof enabled!=='boolean')throw new Error('Autostart must be enabled or disabled.');
|
|
45
|
+
if(!plan)throw new Error(unavailable);
|
|
46
|
+
if(enabled){await mkdir(dirname(plan.path),{recursive:true});if(plan.command){try{await execute(plan.command.file,plan.command.args,{windowsHide:true,timeout:15000,maxBuffer:16384});}catch{throw new Error('Could not create the Windows startup shortcut. Check access to your Startup folder.');}}else await writeFile(plan.path,plan.content,{mode:0o600});}
|
|
47
|
+
else try{await unlink(plan.path);}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
48
|
+
console.log('[worker-launcher] Login startup updated',{enabled,platform:options.platform||process.platform});
|
|
49
|
+
return this.status();
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
import {homedir} from 'node:os';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {createFleet} from './fleet.js';
|
|
5
|
+
import {createLauncherServer} from './server.js';
|
|
6
|
+
import {createAutostart} from './autostart.js';
|
|
7
|
+
import {createCredentialStore} from './credentials.js';
|
|
8
|
+
import {createDockerReadiness} from './docker-readiness.js';
|
|
9
|
+
import {createRegistryAccess} from './registry-access.js';
|
|
10
|
+
|
|
11
|
+
export async function run(args=process.argv.slice(2)){
|
|
12
|
+
if(args[0]==='version'||args[0]==='--version'){console.log(JSON.parse(await readFile(new URL('../package.json',import.meta.url),'utf8')).version);return;}
|
|
13
|
+
if(args.includes('--help')||args.includes('-h')){console.log('Needle Worker Launcher\n\n needle-worker-launcher start [--port 8425] [--state-dir PATH]\n needle-worker-launcher version\n\nOpen the printed localhost URL. Containers start only when requested in the UI.');return;}
|
|
14
|
+
const values={port:8425,directory:join(homedir(),'.needle-worker-launcher')};
|
|
15
|
+
if(args[0]==='start')args=args.slice(1);
|
|
16
|
+
for(let i=0;i<args.length;i++){if(args[i]==='--port')values.port=Number(args[++i]);else if(args[i]==='--state-dir')values.directory=args[++i];else throw new Error('Unknown argument. Use --help.');}
|
|
17
|
+
if(!Number.isInteger(values.port)||values.port<1024||values.port>65535||!values.directory)throw new Error('Specify a port from 1024 to 65535 and a valid settings directory.');
|
|
18
|
+
const credentials=createCredentialStore({directory:join(values.directory,'credentials')});
|
|
19
|
+
const registry=await createRegistryAccess({directory:values.directory,credentials:createCredentialStore({directory:join(values.directory,'credentials'),purpose:'registry'})});
|
|
20
|
+
|
|
21
|
+
const autostart=createAutostart({directory:values.directory,port:values.port});
|
|
22
|
+
const readiness=createDockerReadiness();
|
|
23
|
+
const manager=await createFleet({directory:values.directory,credentials,registry,readiness});
|
|
24
|
+
const server=createLauncherServer(manager,{autostart,registry});
|
|
25
|
+
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(values.port,'127.0.0.1',resolve);});
|
|
26
|
+
console.log(`Needle Worker Launcher\nOpen http://127.0.0.1:${values.port}\nPreviously enabled workers recover automatically. Press Ctrl+C to close the UI; existing containers keep running.`);
|
|
27
|
+
manager.startLoop();
|
|
28
|
+
for(const signal of ['SIGINT','SIGTERM'])process.once(signal,()=>{manager.close();server.close(()=>process.exit(0));});
|
|
29
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
import {readFile,writeFile,mkdir,rename,unlink} from 'node:fs/promises';
|
|
3
|
+
import {createHash} from 'node:crypto';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
|
|
6
|
+
const service='tools.needle.worker-launcher';
|
|
7
|
+
export function runCredentialCommand(file,args,{input=''}={}){
|
|
8
|
+
return new Promise((resolve,reject)=>{
|
|
9
|
+
const child=spawn(file,args,{shell:false,windowsHide:true,stdio:['pipe','pipe','pipe']});
|
|
10
|
+
let stdout='',stderr='',size=0,settled=false;
|
|
11
|
+
const finish=(error,value)=>{if(settled)return;settled=true;clearTimeout(timer);error?reject(error):resolve(value);};
|
|
12
|
+
const timer=setTimeout(()=>{child.kill();finish(new Error('OS credential store timed out. Unlock your login keychain and try again.'));},20000);
|
|
13
|
+
const capture=target=>chunk=>{size+=chunk.length;if(size>16384){child.kill();finish(new Error('OS credential store returned unexpected output.'));}else if(target==='out')stdout+=chunk;else stderr+=chunk;};
|
|
14
|
+
child.stdout.on('data',capture('out'));child.stderr.on('data',capture('err'));
|
|
15
|
+
child.on('error',()=>finish(new Error('OS credential tool is unavailable. Linux requires secret-tool and an unlocked Secret Service keyring.')));
|
|
16
|
+
child.on('close',code=>finish(null,{code,stdout:stdout.trim(),stderr:stderr.trim()}));
|
|
17
|
+
child.stdin.on('error',()=>finish(new Error('Could not deliver the worker key to the OS credential store.')));
|
|
18
|
+
child.stdin.end(input);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Per-user OS protection only. No plaintext fallback or secret-bearing command arguments. */
|
|
23
|
+
export function createCredentialStore({directory,platform=process.platform,execute=runCredentialCommand,purpose='worker'}={}){
|
|
24
|
+
if(!['worker','registry'].includes(purpose))throw new Error('Invalid credential purpose.');
|
|
25
|
+
const serviceName=purpose==='registry'?service+'.registry':service;
|
|
26
|
+
const account=id=>createHash('sha256').update(purpose==='registry'?'registry:'+id:id).digest('hex');
|
|
27
|
+
const file=id=>join(directory,account(id)+'.dpapi');
|
|
28
|
+
const failure=()=>new Error('Could not access the OS credential store. Unlock macOS Keychain or the Linux Secret Service keyring; Linux also requires secret-tool.');
|
|
29
|
+
const powershell=async(operation,input)=>{
|
|
30
|
+
const prefix="$ErrorActionPreference='Stop'; Add-Type -AssemblyName System.Security; $inputValue=[Console]::In.ReadToEnd(); ";
|
|
31
|
+
const script=operation==='protect'?"$bytes=[Text.Encoding]::UTF8.GetBytes($inputValue); [Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect($bytes,$null,'CurrentUser'))":"$bytes=[Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($inputValue),$null,'CurrentUser'); [Text.Encoding]::UTF8.GetString($bytes)";
|
|
32
|
+
const result=await execute('powershell.exe',['-NoProfile','-NonInteractive','-EncodedCommand',Buffer.from(prefix+script,'utf16le').toString('base64')],{input});
|
|
33
|
+
if(result.code!==0||!result.stdout)throw new Error('Windows could not protect or unlock the worker key for this login account.');return result.stdout.trim();
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
kind:platform==='win32'?'Windows account encryption':platform==='darwin'?'macOS Keychain':'Linux Secret Service',
|
|
37
|
+
async get(id){
|
|
38
|
+
if(platform==='win32'){let blob;try{blob=await readFile(file(id),'utf8');}catch(error){if(error.code==='ENOENT')return '';throw error;}return powershell('unprotect',blob);}
|
|
39
|
+
const args=platform==='darwin'?['find-generic-password','-s',serviceName,'-a',account(id),'-w']:['lookup','service',serviceName,'account',account(id)];
|
|
40
|
+
const result=await execute(platform==='darwin'?'/usr/bin/security':'secret-tool',args);
|
|
41
|
+
if(result.code===0)return result.stdout.trim();
|
|
42
|
+
if(platform==='darwin'&&result.code===44||platform==='linux'&&result.code===1&&!result.stderr)return '';
|
|
43
|
+
throw failure();
|
|
44
|
+
},
|
|
45
|
+
async set(id,key){
|
|
46
|
+
if(typeof key!=='string'||!(purpose==='registry'?/^ncr_[A-Za-z0-9_-]{1,8192}$/:/^ncw_[a-f0-9]{64}$/).test(key))throw new Error(`Invalid ${purpose} credential.`);
|
|
47
|
+
if(platform==='win32'){const blob=await powershell('protect',key);await mkdir(directory,{recursive:true,mode:0o700});await writeFile(file(id)+'.tmp',blob,{mode:0o600});await rename(file(id)+'.tmp',file(id));if(await this.get(id)!==key)throw new Error('Windows could not verify the saved worker credential.');return;}
|
|
48
|
+
if(!['darwin','linux'].includes(platform))throw failure();
|
|
49
|
+
// security's interactive command parser reads this pipe; the key never appears in argv.
|
|
50
|
+
const args=platform==='darwin'?['-q','-i']:['store','--label=Needle Worker Launcher','service',serviceName,'account',account(id)];
|
|
51
|
+
const input=platform==='darwin'?`add-generic-password -U -s ${serviceName} -a ${account(id)} -w ${key}\n`:key;
|
|
52
|
+
const result=await execute(platform==='darwin'?'/usr/bin/security':'secret-tool',args,{input});if(result.code!==0||await this.get(id)!==key)throw failure();
|
|
53
|
+
},
|
|
54
|
+
async remove(id){
|
|
55
|
+
if(platform==='win32'){try{await unlink(file(id));}catch(error){if(error.code!=='ENOENT')throw error;}return;}
|
|
56
|
+
const result=await execute(platform==='darwin'?'/usr/bin/security':'secret-tool',platform==='darwin'?['delete-generic-password','-s',serviceName,'-a',account(id)]:['clear','service',serviceName,'account',account(id)]);
|
|
57
|
+
if(result.code!==0&&!(platform==='darwin'&&result.code===44||platform==='linux'&&result.code===1&&!result.stderr))throw failure();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/** Arguments never pass through a shell. Only the container receives the worker key. */
|
|
4
|
+
export function executeDocker(args,{environment={},timeout=15000}={}){
|
|
5
|
+
return new Promise((resolve,reject)=>{
|
|
6
|
+
const {NEEDLE_SHARE_API_KEY,DOCKER_ACCESS_TOKEN,...inherited}=process.env;
|
|
7
|
+
const child=spawn('docker',args,{shell:false,windowsHide:true,env:{...inherited,...environment},stdio:['ignore','pipe','pipe']});
|
|
8
|
+
let output='',length=0,failed=false;
|
|
9
|
+
const timer=setTimeout(()=>{failed=true;child.kill();reject(new Error(`Docker ${args[0]} timed out. Check Docker Desktop and registry access.`));},timeout);
|
|
10
|
+
child.stdout.on('data',chunk=>{length+=chunk.length;if(length>1024*1024){failed=true;child.kill();clearTimeout(timer);reject(new Error('Docker output exceeded the safety limit.'));}else output+=chunk;});
|
|
11
|
+
// Never expose raw daemon output: container diagnostics can include environment values.
|
|
12
|
+
child.stderr.resume();
|
|
13
|
+
child.on('error',cause=>{clearTimeout(timer);failed=true;const error=new Error(cause.code==='ENOENT'?'Docker is not installed or is not on PATH. Install Docker Desktop or Docker Engine.':'Docker could not start. Check Docker installation and permissions.');error.code=cause.code;reject(error);});
|
|
14
|
+
child.on('close',code=>{clearTimeout(timer);if(failed)return;if(code!==0)reject(new Error(`Docker ${args[0]} failed (exit ${code}). Check Docker Desktop, image availability and registry login.`));else resolve(output.trim());});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {access} from 'node:fs/promises';
|
|
2
|
+
import {spawn} from 'node:child_process';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {homedir} from 'node:os';
|
|
5
|
+
import {executeDocker} from './docker-client.js';
|
|
6
|
+
|
|
7
|
+
async function exists(path){try{await access(path);return true;}catch(error){if(error.code==='ENOENT')return false;throw error;}}
|
|
8
|
+
function launch(file,args){return new Promise((resolve,reject)=>{const child=spawn(file,args,{detached:true,stdio:'ignore',windowsHide:true,shell:false});child.once('error',()=>reject(new Error('Docker Desktop could not start. Open it manually and finish any setup.')));child.once('spawn',()=>{child.unref();resolve();});});}
|
|
9
|
+
|
|
10
|
+
export function createDockerReadiness({docker=executeDocker,platform=process.platform,environment=process.env,exists:hasFile=exists,launch:startApp=launch,wait=ms=>new Promise(resolve=>setTimeout(resolve,ms)),now=Date.now,onState=()=>{}}={}){
|
|
11
|
+
let state={available:false,phase:'checking'},lastLaunch=-Infinity;
|
|
12
|
+
const update=value=>{state={...state,...value};onState({...state});};
|
|
13
|
+
async function probe(){
|
|
14
|
+
try{await docker(['--version'],{timeout:5000});}catch(error){if(error.code==='ENOENT'||/not installed|not on PATH/i.test(error.message)){const issue=new Error('Docker is not installed or is not on PATH. Install Docker Desktop on Windows/macOS, or Docker Engine on Linux.');issue.code='DOCKER_MISSING';update({available:false,phase:'missing',error:issue.message});throw issue;}throw error;}
|
|
15
|
+
let info;
|
|
16
|
+
try{info=JSON.parse(await docker(['info','--format','{{json .}}'],{timeout:5000}));}
|
|
17
|
+
catch(error){update({available:false,phase:'stopped',error:error.message});return {...state};}
|
|
18
|
+
if(info.OSType!=='linux'){const error=new Error('Switch Docker Desktop to Linux containers before starting workers.');error.code='DOCKER_MODE';update({available:false,phase:'blocked',error:error.message});throw error;}
|
|
19
|
+
update({available:true,phase:'ready',error:undefined,version:info.ServerVersion,architecture:info.Architecture});return {...state};
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
status:()=>({...state}),probe,
|
|
23
|
+
async ensure(){
|
|
24
|
+
if((await probe()).available)return;
|
|
25
|
+
if(platform==='linux')throw new Error('Docker is installed but unreachable. Start the Docker service (sudo systemctl start docker) or your rootless Docker service, and check your user’s Docker permissions.');
|
|
26
|
+
if(!['win32','darwin'].includes(platform))throw new Error('Start Docker manually on this platform.');
|
|
27
|
+
if(now()-lastLaunch>300000){
|
|
28
|
+
const candidates=platform==='win32'?[join(environment.ProgramFiles||'C:\\Program Files','Docker','Docker','Docker Desktop.exe'),...(environment.LOCALAPPDATA?[join(environment.LOCALAPPDATA,'Docker','Docker Desktop.exe')]:[])]:['/Applications/Docker.app',join(homedir(),'Applications','Docker.app')];
|
|
29
|
+
let application;for(const candidate of candidates)if(await hasFile(candidate)){application=candidate;break;}
|
|
30
|
+
if(!application)throw new Error('Docker Desktop was not found. Install it or open your custom installation manually.');
|
|
31
|
+
update({phase:'starting',error:undefined});
|
|
32
|
+
if(platform==='win32')await startApp(application,[]);else await startApp('/usr/bin/open',['-g','-a',application]);
|
|
33
|
+
lastLaunch=now();
|
|
34
|
+
}
|
|
35
|
+
update({phase:'waiting',error:undefined});const deadline=now()+120000;
|
|
36
|
+
while(now()<deadline){await wait(2000);if((await probe()).available)return;update({phase:'waiting'});}
|
|
37
|
+
throw new Error('Docker Desktop did not become ready within two minutes. Open it and finish setup, then retry.');
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
package/src/fleet.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {readFile,writeFile,mkdir,rename} from 'node:fs/promises';
|
|
2
|
+
import {join} from 'node:path';import {randomUUID} from 'node:crypto';
|
|
3
|
+
import {executeDocker} from './docker-client.js';
|
|
4
|
+
import {createManager} from './manager.js';import {createSupervisor} from './supervisor.js';
|
|
5
|
+
|
|
6
|
+
/** Local configurations keep separate persistent manager IDs, desired states and ownership labels. */
|
|
7
|
+
export async function createFleet({directory,credentials,registry,readiness,docker,createSuper=createSupervisor,resolveName}){
|
|
8
|
+
await mkdir(directory,{recursive:true,mode:0o700});const file=join(directory,'profiles.json');
|
|
9
|
+
let entries=[];
|
|
10
|
+
try{const saved=JSON.parse(await readFile(file,'utf8'));if(saved.version!==1||!Array.isArray(saved.profiles)||saved.profiles.length>32||saved.profiles.some(id=>id!=='legacy'&&!/^[a-f0-9-]{36}$/.test(id))||new Set(saved.profiles).size!==saved.profiles.length)throw Error('Invalid launcher profile index');entries=saved.profiles;}
|
|
11
|
+
catch(error){if(error.code!=='ENOENT')throw error;try{const previous=JSON.parse(await readFile(join(directory,'settings.json'),'utf8'));if(previous.settings?.workerId)entries=['legacy'];}catch(cause){if(cause.code!=='ENOENT')throw cause;}}
|
|
12
|
+
const profiles=new Map();let looping=false,queue=Promise.resolve();
|
|
13
|
+
async function persist(){await writeFile(file+'.tmp',JSON.stringify({version:1,profiles:entries}),{mode:0o600});await rename(file+'.tmp',file);}
|
|
14
|
+
async function open(id){const manager=await createManager({directory:id==='legacy'?directory:join(directory,'workers',id),credentials,registry,...(docker?{docker}:{}),...(resolveName?{resolveName}:{})});const supervisor=createSuper({manager,readiness});const profile={manager,supervisor};profiles.set(id,profile);if(looping)supervisor.startLoop();return profile;}
|
|
15
|
+
for(const id of entries)await open(id);await persist();
|
|
16
|
+
function get(id){if(!id||!profiles.has(id))throw Error('Worker configuration not found');return profiles.get(id);}
|
|
17
|
+
return {
|
|
18
|
+
async status(){const items=await Promise.all(entries.map(async profileId=>{const profile=get(profileId);return {...await profile.manager.status(),profileId,supervision:profile.supervisor.status()};}));if(items.length)return {...items[0],profiles:items};
|
|
19
|
+
let info,dockerState;try{info=JSON.parse(await (docker||executeDocker)(['info','--format','{{json .}}']));dockerState={available:true,version:info.ServerVersion,architecture:info.Architecture};}catch(error){dockerState={available:false,error:error.message};}
|
|
20
|
+
return {profiles:[],settings:{environment:'staging',workerId:'',count:1,cpus:2,memory:4},containers:[],docker:dockerState,keyProtection:credentials?.kind||'OS credential store'};},
|
|
21
|
+
async configure(value){const action=queue.then(async()=>{if(value.profileId){await get(value.profileId).manager.configure(value);return;}if(entries.length>=32)throw Error('Up to 32 worker configurations are supported');const id=entries.length===0?'legacy':randomUUID();const profile=await open(id);try{await profile.manager.configure(value);}catch(error){profile.supervisor.close();profiles.delete(id);throw error;}entries=[...entries,id];await persist();});queue=action.catch(()=>{});return action;},
|
|
22
|
+
async remove(id,confirmed){if(confirmed!==true)throw Error('Confirm removing this local worker configuration.');const action=queue.then(async()=>{const profile=get(id);await profile.manager.forget();profile.supervisor.close();entries=entries.filter(value=>value!==id);await persist();profiles.delete(id);});queue=action.catch(()=>{});return action;},
|
|
23
|
+
async start(id){await get(id).supervisor.start();},
|
|
24
|
+
async stop(confirmed,id){if(confirmed!==true)throw Error('Confirm that stopping may interrupt active jobs.');await get(id).supervisor.stop(true);},
|
|
25
|
+
startLoop(){looping=true;for(const profile of profiles.values())profile.supervisor.startLoop();},
|
|
26
|
+
close(){looping=false;for(const profile of profiles.values())profile.supervisor.close();}
|
|
27
|
+
};
|
|
28
|
+
}
|