pinokiod 5.3.5 → 5.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server/index.js +86 -0
- package/server/views/registry_checkin.ejs +272 -0
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -44,6 +44,10 @@ const DEFAULT_PORT = 42000
|
|
|
44
44
|
const NOTIFICATION_SOUND_EXTENSIONS = new Set(['.aac', '.flac', '.m4a', '.mp3', '.ogg', '.wav', '.webm'])
|
|
45
45
|
const LOG_STREAM_INITIAL_BYTES = 512 * 1024
|
|
46
46
|
const LOG_STREAM_KEEPALIVE_MS = 25000
|
|
47
|
+
const REGISTRY_PING_PNG = Buffer.from(
|
|
48
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMBAAObF+6bAAAAAElFTkSuQmCC',
|
|
49
|
+
'base64'
|
|
50
|
+
)
|
|
47
51
|
|
|
48
52
|
const ex = fn => (req, res, next) => {
|
|
49
53
|
Promise.resolve(fn(req, res, next)).catch(next);
|
|
@@ -4836,6 +4840,88 @@ class Server {
|
|
|
4836
4840
|
})
|
|
4837
4841
|
}))
|
|
4838
4842
|
|
|
4843
|
+
this.app.get("/registry/ping.png", ex(async (_req, res) => {
|
|
4844
|
+
res.setHeader('Content-Type', 'image/png')
|
|
4845
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
4846
|
+
res.setHeader('Content-Length', String(REGISTRY_PING_PNG.length))
|
|
4847
|
+
res.end(REGISTRY_PING_PNG)
|
|
4848
|
+
}))
|
|
4849
|
+
|
|
4850
|
+
this.app.get("/registry/checkin", ex(async (req, res) => {
|
|
4851
|
+
const repoUrl = typeof req.query.repo === 'string' ? req.query.repo.trim() : ''
|
|
4852
|
+
const returnRaw = typeof req.query.return === 'string' ? req.query.return.trim() : ''
|
|
4853
|
+
const successRaw = typeof req.query.success === 'string' ? req.query.success.trim() : ''
|
|
4854
|
+
const appSlug = typeof req.query.app === 'string' ? req.query.app.trim() : ''
|
|
4855
|
+
const registryEnabled = await this.isRegistryBetaEnabled().catch(() => false)
|
|
4856
|
+
|
|
4857
|
+
let returnUrl = null
|
|
4858
|
+
if (returnRaw) {
|
|
4859
|
+
try {
|
|
4860
|
+
const u = new URL(returnRaw)
|
|
4861
|
+
if (u.protocol === 'http:' || u.protocol === 'https:') returnUrl = u.toString()
|
|
4862
|
+
} catch (_) {}
|
|
4863
|
+
}
|
|
4864
|
+
let successUrl = null
|
|
4865
|
+
if (successRaw) {
|
|
4866
|
+
try {
|
|
4867
|
+
const u = new URL(successRaw)
|
|
4868
|
+
if (u.protocol === 'http:' || u.protocol === 'https:') successUrl = u.toString()
|
|
4869
|
+
} catch (_) {}
|
|
4870
|
+
}
|
|
4871
|
+
|
|
4872
|
+
let candidates = []
|
|
4873
|
+
if (repoUrl && this.kernel && this.kernel.git) {
|
|
4874
|
+
const apiRoot = this.kernel.path('api')
|
|
4875
|
+
const normalizeKey = (value) => {
|
|
4876
|
+
const key = this.kernel.git.normalizeRemote(value)
|
|
4877
|
+
return key ? String(key).toLowerCase() : ''
|
|
4878
|
+
}
|
|
4879
|
+
const targetKey = normalizeKey(repoUrl)
|
|
4880
|
+
const seen = new Set()
|
|
4881
|
+
if (targetKey) {
|
|
4882
|
+
let entries = []
|
|
4883
|
+
try {
|
|
4884
|
+
entries = await fs.promises.readdir(apiRoot, { withFileTypes: true })
|
|
4885
|
+
} catch (_) {
|
|
4886
|
+
entries = []
|
|
4887
|
+
}
|
|
4888
|
+
for (const entry of entries || []) {
|
|
4889
|
+
if (!entry || !entry.isDirectory()) continue
|
|
4890
|
+
const folder = entry.name
|
|
4891
|
+
if (!folder || folder.includes('/') || folder.includes('\\\\')) continue
|
|
4892
|
+
const workspace = path.join(apiRoot, folder)
|
|
4893
|
+
let remote = null
|
|
4894
|
+
try {
|
|
4895
|
+
remote = await git.getConfig({
|
|
4896
|
+
fs,
|
|
4897
|
+
http,
|
|
4898
|
+
dir: workspace,
|
|
4899
|
+
path: 'remote.origin.url'
|
|
4900
|
+
})
|
|
4901
|
+
} catch (_) {}
|
|
4902
|
+
if (!remote) continue
|
|
4903
|
+
const key = normalizeKey(remote)
|
|
4904
|
+
if (!key || key !== targetKey) continue
|
|
4905
|
+
if (seen.has(folder)) continue
|
|
4906
|
+
seen.add(folder)
|
|
4907
|
+
candidates.push({ folder })
|
|
4908
|
+
}
|
|
4909
|
+
}
|
|
4910
|
+
}
|
|
4911
|
+
|
|
4912
|
+
const data = {
|
|
4913
|
+
repoUrl,
|
|
4914
|
+
appSlug,
|
|
4915
|
+
returnUrl,
|
|
4916
|
+
successUrl,
|
|
4917
|
+
registryEnabled: !!registryEnabled,
|
|
4918
|
+
candidates,
|
|
4919
|
+
}
|
|
4920
|
+
const dataJson = JSON.stringify(data).replace(/</g, '\\u003c')
|
|
4921
|
+
|
|
4922
|
+
res.render("registry_checkin", { returnUrl, dataJson })
|
|
4923
|
+
}))
|
|
4924
|
+
|
|
4839
4925
|
// Registry linking endpoint
|
|
4840
4926
|
this.app.get("/registry/link", ex(async (req, res) => {
|
|
4841
4927
|
const { token, registry, app: appOrigin, next: nextRaw } = req.query
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<style>
|
|
7
|
+
body {
|
|
8
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
justify-content: center;
|
|
12
|
+
min-height: 100vh;
|
|
13
|
+
margin: 0;
|
|
14
|
+
background: #f5f5f5;
|
|
15
|
+
color: #0f172a;
|
|
16
|
+
}
|
|
17
|
+
.card {
|
|
18
|
+
background: white;
|
|
19
|
+
padding: 32px;
|
|
20
|
+
border-radius: 12px;
|
|
21
|
+
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.08);
|
|
22
|
+
max-width: 520px;
|
|
23
|
+
width: calc(100% - 32px);
|
|
24
|
+
}
|
|
25
|
+
h1 {
|
|
26
|
+
margin: 0 0 8px;
|
|
27
|
+
font-size: 22px;
|
|
28
|
+
}
|
|
29
|
+
.meta {
|
|
30
|
+
font-size: 13px;
|
|
31
|
+
color: #64748b;
|
|
32
|
+
margin-bottom: 16px;
|
|
33
|
+
word-break: break-all;
|
|
34
|
+
}
|
|
35
|
+
.status {
|
|
36
|
+
padding: 10px 12px;
|
|
37
|
+
border-radius: 8px;
|
|
38
|
+
background: #eef2ff;
|
|
39
|
+
color: #1e3a8a;
|
|
40
|
+
font-size: 14px;
|
|
41
|
+
margin-bottom: 16px;
|
|
42
|
+
}
|
|
43
|
+
.status.error {
|
|
44
|
+
background: #fee2e2;
|
|
45
|
+
color: #991b1b;
|
|
46
|
+
}
|
|
47
|
+
.status.ok {
|
|
48
|
+
background: #dcfce7;
|
|
49
|
+
color: #166534;
|
|
50
|
+
}
|
|
51
|
+
.picker {
|
|
52
|
+
display: none;
|
|
53
|
+
margin-bottom: 16px;
|
|
54
|
+
}
|
|
55
|
+
.picker.visible {
|
|
56
|
+
display: block;
|
|
57
|
+
}
|
|
58
|
+
.picker-list {
|
|
59
|
+
display: grid;
|
|
60
|
+
gap: 8px;
|
|
61
|
+
}
|
|
62
|
+
.picker-item {
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
gap: 8px;
|
|
66
|
+
padding: 10px 12px;
|
|
67
|
+
border: 1px solid #e2e8f0;
|
|
68
|
+
border-radius: 8px;
|
|
69
|
+
background: #f8fafc;
|
|
70
|
+
cursor: pointer;
|
|
71
|
+
}
|
|
72
|
+
.picker-item input {
|
|
73
|
+
margin: 0;
|
|
74
|
+
}
|
|
75
|
+
.actions {
|
|
76
|
+
display: flex;
|
|
77
|
+
gap: 10px;
|
|
78
|
+
flex-wrap: wrap;
|
|
79
|
+
}
|
|
80
|
+
.btn {
|
|
81
|
+
border: none;
|
|
82
|
+
border-radius: 8px;
|
|
83
|
+
padding: 10px 16px;
|
|
84
|
+
font-size: 14px;
|
|
85
|
+
cursor: pointer;
|
|
86
|
+
}
|
|
87
|
+
.btn-primary {
|
|
88
|
+
background: #2563eb;
|
|
89
|
+
color: white;
|
|
90
|
+
}
|
|
91
|
+
.btn-secondary {
|
|
92
|
+
background: #e2e8f0;
|
|
93
|
+
color: #0f172a;
|
|
94
|
+
}
|
|
95
|
+
.link {
|
|
96
|
+
display: inline-block;
|
|
97
|
+
margin-top: 12px;
|
|
98
|
+
color: #2563eb;
|
|
99
|
+
text-decoration: none;
|
|
100
|
+
font-size: 14px;
|
|
101
|
+
}
|
|
102
|
+
.link:hover {
|
|
103
|
+
text-decoration: underline;
|
|
104
|
+
}
|
|
105
|
+
</style>
|
|
106
|
+
</head>
|
|
107
|
+
<body>
|
|
108
|
+
<div class="card">
|
|
109
|
+
<h1>Check-in to Registry</h1>
|
|
110
|
+
<div class="meta" id="repo">Loading...</div>
|
|
111
|
+
<div class="status" id="status">Preparing check-in...</div>
|
|
112
|
+
|
|
113
|
+
<div class="picker" id="picker">
|
|
114
|
+
<div style="font-weight: 600; margin-bottom: 8px;">Choose an install</div>
|
|
115
|
+
<div class="picker-list" id="picker-list"></div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div class="actions" id="actions">
|
|
119
|
+
<button class="btn btn-primary" id="picker-submit" type="button">Check in</button>
|
|
120
|
+
<button class="btn btn-secondary" id="picker-cancel" type="button">Cancel</button>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<% if (returnUrl) { %>
|
|
124
|
+
<a class="link" id="return-link" href="<%= returnUrl %>">Return to registry</a>
|
|
125
|
+
<% } else { %>
|
|
126
|
+
<a class="link" href="/">Go back to Pinokio</a>
|
|
127
|
+
<% } %>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<script>
|
|
131
|
+
const data = <%- dataJson %>;
|
|
132
|
+
const repoEl = document.getElementById('repo');
|
|
133
|
+
const statusEl = document.getElementById('status');
|
|
134
|
+
const pickerEl = document.getElementById('picker');
|
|
135
|
+
const listEl = document.getElementById('picker-list');
|
|
136
|
+
const actionsEl = document.getElementById('actions');
|
|
137
|
+
const submitBtn = document.getElementById('picker-submit');
|
|
138
|
+
const cancelBtn = document.getElementById('picker-cancel');
|
|
139
|
+
if (actionsEl) actionsEl.style.display = 'none';
|
|
140
|
+
|
|
141
|
+
const setStatus = (text, kind) => {
|
|
142
|
+
statusEl.textContent = text;
|
|
143
|
+
statusEl.className = 'status' + (kind ? ' ' + kind : '');
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const redirectToReturn = (params) => {
|
|
147
|
+
if (!data.returnUrl) {
|
|
148
|
+
setStatus('Missing return URL. Please go back manually.', 'error');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const u = new URL(data.returnUrl);
|
|
153
|
+
Object.entries(params || {}).forEach(([key, value]) => {
|
|
154
|
+
if (value == null || value === '') return;
|
|
155
|
+
u.searchParams.set(key, String(value));
|
|
156
|
+
});
|
|
157
|
+
window.location.href = u.toString();
|
|
158
|
+
} catch (err) {
|
|
159
|
+
setStatus('Invalid return URL. Please go back manually.', 'error');
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const buildSuccessUrl = (hash) => {
|
|
164
|
+
if (!data.successUrl || !hash) return null;
|
|
165
|
+
const replaced = data.successUrl.replace(/__HASH__/g, encodeURIComponent(hash));
|
|
166
|
+
try {
|
|
167
|
+
const u = new URL(replaced);
|
|
168
|
+
if (u.protocol === 'http:' || u.protocol === 'https:') return u.toString();
|
|
169
|
+
} catch (err) {}
|
|
170
|
+
return null;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const candidates = Array.isArray(data.candidates) ? data.candidates : [];
|
|
174
|
+
const repoLabel = data.repoUrl ? `Repo: ${data.repoUrl}` : 'Missing repo URL';
|
|
175
|
+
if (repoEl) repoEl.textContent = repoLabel;
|
|
176
|
+
|
|
177
|
+
if (!data.repoUrl) {
|
|
178
|
+
setStatus('Missing repo URL. Return to the registry and try again.', 'error');
|
|
179
|
+
} else if (!data.returnUrl) {
|
|
180
|
+
setStatus('Missing return URL. Return to the registry and try again.', 'error');
|
|
181
|
+
} else if (!data.registryEnabled) {
|
|
182
|
+
redirectToReturn({ error: 'not_enabled' });
|
|
183
|
+
} else if (!candidates.length) {
|
|
184
|
+
redirectToReturn({ error: 'not_installed' });
|
|
185
|
+
} else if (candidates.length === 1) {
|
|
186
|
+
const entry = candidates[0];
|
|
187
|
+
runCheckin(entry.folder);
|
|
188
|
+
} else {
|
|
189
|
+
setStatus('Multiple installs found. Choose one to check in.', '');
|
|
190
|
+
pickerEl.classList.add('visible');
|
|
191
|
+
if (actionsEl) actionsEl.style.display = 'flex';
|
|
192
|
+
candidates.forEach((entry, idx) => {
|
|
193
|
+
const label = document.createElement('label');
|
|
194
|
+
label.className = 'picker-item';
|
|
195
|
+
const input = document.createElement('input');
|
|
196
|
+
input.type = 'radio';
|
|
197
|
+
input.name = 'workspace';
|
|
198
|
+
input.value = entry.folder;
|
|
199
|
+
if (idx === 0) input.checked = true;
|
|
200
|
+
const span = document.createElement('span');
|
|
201
|
+
span.textContent = entry.folder;
|
|
202
|
+
label.appendChild(input);
|
|
203
|
+
label.appendChild(span);
|
|
204
|
+
listEl.appendChild(label);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (cancelBtn) {
|
|
209
|
+
cancelBtn.addEventListener('click', () => {
|
|
210
|
+
redirectToReturn({ error: 'cancelled' });
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (submitBtn) {
|
|
215
|
+
submitBtn.addEventListener('click', () => {
|
|
216
|
+
const selected = document.querySelector('input[name="workspace"]:checked');
|
|
217
|
+
const folder = selected && selected.value ? selected.value : '';
|
|
218
|
+
if (!folder) {
|
|
219
|
+
setStatus('Select a folder to continue.', 'error');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
runCheckin(folder);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function runCheckin(folder) {
|
|
227
|
+
if (!folder) {
|
|
228
|
+
setStatus('Missing folder selection.', 'error');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
setStatus('Creating checkpoint...', '');
|
|
232
|
+
if (submitBtn) submitBtn.disabled = true;
|
|
233
|
+
if (cancelBtn) cancelBtn.disabled = true;
|
|
234
|
+
try {
|
|
235
|
+
const qs = new URLSearchParams({ workspace: folder, publish: '1' });
|
|
236
|
+
const res = await fetch(`/checkpoints/snapshot?${qs.toString()}`, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: { 'Accept': 'application/json' }
|
|
239
|
+
});
|
|
240
|
+
const payload = res.ok ? await res.json().catch(() => null) : null;
|
|
241
|
+
if (!payload || !payload.ok) {
|
|
242
|
+
redirectToReturn({ error: 'snapshot_failed' });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const publish = payload.publish || null;
|
|
246
|
+
if (publish && publish.ok) {
|
|
247
|
+
const hash = publish.hash || (payload.created && payload.created.hash) || '';
|
|
248
|
+
const successUrl = buildSuccessUrl(hash);
|
|
249
|
+
if (successUrl) {
|
|
250
|
+
window.location.href = successUrl;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
redirectToReturn({ ok: '1', hash });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (publish && publish.code === 'not_linked') {
|
|
257
|
+
redirectToReturn({ error: 'not_linked', connectUrl: publish.connectUrl || '' });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (publish && publish.code === 'error') {
|
|
261
|
+
redirectToReturn({ error: 'publish_failed', message: publish.error || '' });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
redirectToReturn({ error: 'not_enabled' });
|
|
265
|
+
} catch (err) {
|
|
266
|
+
const msg = err && err.message ? err.message : 'Publish failed';
|
|
267
|
+
redirectToReturn({ error: 'publish_failed', message: msg });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
</script>
|
|
271
|
+
</body>
|
|
272
|
+
</html>
|