dsh-gh-pages-artifacts 0.1.0

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.
@@ -0,0 +1,564 @@
1
+ // Browser half of dsh-gh-pages-artifacts: the plugin's settings page on the dsh Plugins page.
2
+ // Plain JS in the client module format dsh loads (React comes from the host's module table), so
3
+ // the package needs no client build step. Styles use the host's --dsw-alias-* theme tokens.
4
+ window.__ModuleLoader__.load({
5
+ id: 'dsh-gh-pages-artifacts',
6
+ factory(require) {
7
+ const React = require('react')
8
+ const h = React.createElement
9
+ const { useEffect, useMemo, useState, useSyncExternalStore } = React
10
+
11
+ /** Package name, and the settings namespace: the row id the bundle patch declares. */
12
+ const PKG = 'dsh-gh-pages-artifacts'
13
+ const NS = 'gh-pages-artifacts'
14
+ const DEFAULT_TOKEN_ENV = 'GH_PAGES_TOKEN'
15
+ const TOKEN_HELP = 'https://github.com/settings/personal-access-tokens/new'
16
+
17
+ const css = {
18
+ page: { display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 720, color: 'var(--dsw-alias-label-primary)', fontSize: 13 },
19
+ section: { display: 'flex', flexDirection: 'column', gap: 12, padding: 16, borderRadius: 10, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)' },
20
+ heading: { margin: 0, fontSize: 14, fontWeight: 600 },
21
+ intro: { margin: 0, color: 'var(--dsw-alias-label-secondary)', lineHeight: 1.5 },
22
+ field: { display: 'flex', flexDirection: 'column', gap: 4 },
23
+ labelRow: { display: 'flex', alignItems: 'center', gap: 8 },
24
+ label: { fontWeight: 500 },
25
+ hint: { color: 'var(--dsw-alias-label-tertiary)', lineHeight: 1.4 },
26
+ badge: { fontSize: 11, padding: '1px 6px', borderRadius: 999, background: 'var(--dsw-alias-bg-layer-3)', color: 'var(--dsw-alias-label-secondary)' },
27
+ reset: { marginLeft: 'auto', background: 'none', border: 'none', padding: 0, color: 'var(--dsw-alias-link)', cursor: 'pointer', font: 'inherit', fontSize: 12 },
28
+ input: { font: 'inherit', fontSize: 13, padding: '6px 10px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l3)', background: 'var(--dsw-alias-bg-base)', color: 'var(--dsw-alias-label-primary)', outline: 'none', minWidth: 0 },
29
+ radios: { display: 'flex', flexDirection: 'column', gap: 6 },
30
+ choice: { display: 'flex', alignItems: 'flex-start', gap: 8, cursor: 'pointer', lineHeight: 1.4 },
31
+ row: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
32
+ two: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 8 },
33
+ actions: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
34
+ primary: { font: 'inherit', fontSize: 13, padding: '6px 14px', borderRadius: 6, border: 'none', cursor: 'pointer', background: 'var(--dsw-alias-button-primary-fill)', color: 'var(--dsw-alias-label-primary-foreground)' },
35
+ secondary: { font: 'inherit', fontSize: 13, padding: '6px 14px', borderRadius: 6, cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l3)', background: 'transparent', color: 'var(--dsw-alias-label-primary)' },
36
+ ok: { color: 'var(--dsw-alias-state-success-primary)' },
37
+ error: { color: 'var(--dsw-alias-state-error-primary)' },
38
+ warn: { color: 'var(--dsw-alias-state-warn-primary)' },
39
+ link: { color: 'var(--dsw-alias-link)' },
40
+ code: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12 },
41
+ }
42
+
43
+ /** Editable options, in page order. Paths address the plugin's Config. */
44
+ const FIELDS = {
45
+ repoStrategy: { path: ['repoStrategy'] },
46
+ repository: { path: ['repository'], optional: true },
47
+ owner: { path: ['owner'], optional: true },
48
+ repo: { path: ['repo'] },
49
+ branch: { path: ['branch'] },
50
+ repoPrefix: { path: ['repoPrefix'] },
51
+ repoVisibility: { path: ['repoVisibility'] },
52
+ authorName: { path: ['commitAuthor', 'name'], optional: true, resetPath: ['commitAuthor'] },
53
+ authorEmail: { path: ['commitAuthor', 'email'], optional: true, resetPath: ['commitAuthor'] },
54
+ approval: { path: ['approval'] },
55
+ noindex: { path: ['noindex'] },
56
+ blockSecrets: { path: ['blockSecrets'] },
57
+ tokenEnv: { path: ['tokenEnv'] },
58
+ baseUrl: { path: ['baseUrl'], optional: true },
59
+ }
60
+
61
+ function at(object, path) {
62
+ let value = object
63
+ for (const key of path) {
64
+ if (value === null || typeof value !== 'object') return undefined
65
+ value = value[key]
66
+ }
67
+ return value
68
+ }
69
+
70
+ function present(object, path) {
71
+ let value = object
72
+ for (const key of path) {
73
+ if (value === null || typeof value !== 'object' || !(key in value)) return false
74
+ value = value[key]
75
+ }
76
+ return true
77
+ }
78
+
79
+ function useForm(form) {
80
+ return useSyncExternalStore(listener => form.subscribe(listener), () => form.getSnapshot(), () => form.getSnapshot())
81
+ }
82
+
83
+ /** Reads whether the token credential is configured, re-reading when the Host reports a change. */
84
+ function useCredential(ctx, ref) {
85
+ const [state, setState] = useState({ ref, configured: false, writable: true, source: undefined, loading: true })
86
+ const [tick, setTick] = useState(0)
87
+ useEffect(() => {
88
+ let live = true
89
+ const credentials = ctx.remote && ctx.remote.credentials
90
+ if (!credentials) {
91
+ setState({ ref, configured: false, writable: false, source: undefined, loading: false, unavailable: true })
92
+ return () => { live = false }
93
+ }
94
+ Promise.resolve(credentials.describe([ref])).then((response) => {
95
+ if (!live) return
96
+ const view = response && response.ok ? response.value[ref] : undefined
97
+ setState({ ref, configured: Boolean(view && view.configured), writable: view ? view.writable !== false : true, source: view && view.source, loading: false })
98
+ }, () => { if (live) setState(previous => ({ ...previous, ref, loading: false })) })
99
+ return () => { live = false }
100
+ }, [ctx, ref, tick])
101
+ useEffect(() => {
102
+ if (!ctx.remote || typeof ctx.remote.$on !== 'function') return undefined
103
+ return ctx.remote.$on('credentials/reference-updated', (changed) => { if (changed === ref) setTick(value => value + 1) })
104
+ }, [ctx, ref])
105
+ return [state, () => setTick(value => value + 1)]
106
+ }
107
+
108
+ function Field({ label, hint, overridden, onReset, children }) {
109
+ return h('div', { style: css.field },
110
+ h('div', { style: css.labelRow },
111
+ h('span', { style: css.label }, label),
112
+ overridden ? h('span', { style: css.badge }, 'Set') : null,
113
+ overridden && onReset ? h('button', { type: 'button', style: css.reset, onClick: onReset }, 'Reset to default') : null),
114
+ children,
115
+ hint ? h('span', { style: css.hint }, hint) : null)
116
+ }
117
+
118
+ function TextInput({ value, onChange, placeholder, disabled, type }) {
119
+ return h('input', {
120
+ type: type || 'text', value: value == null ? '' : String(value), placeholder, disabled,
121
+ spellCheck: false, autoComplete: 'off', style: css.input,
122
+ onChange: event => onChange(event.target.value),
123
+ })
124
+ }
125
+
126
+ function Select({ value, options, onChange, disabled }) {
127
+ return h('select', { value, disabled, style: css.input, onChange: event => onChange(event.target.value) },
128
+ options.map(([option, text]) => h('option', { key: option, value: option }, text)))
129
+ }
130
+
131
+ function Toggle({ checked, onChange, disabled, label }) {
132
+ return h('label', { style: css.choice },
133
+ h('input', { type: 'checkbox', checked: Boolean(checked), disabled, onChange: event => onChange(event.target.checked) }),
134
+ h('span', null, label))
135
+ }
136
+
137
+ function TokenSection({ ctx, tokenEnv }) {
138
+ const [credential, refresh] = useCredential(ctx, tokenEnv)
139
+ const [draft, setDraft] = useState('')
140
+ const [message, setMessage] = useState(null)
141
+ const [busy, setBusy] = useState(false)
142
+ const save = async () => {
143
+ if (draft.trim() === '') return
144
+ setBusy(true)
145
+ setMessage(null)
146
+ try {
147
+ await ctx.remote.credentials.set(tokenEnv, draft.trim())
148
+ setDraft('')
149
+ setMessage({ ok: true, text: 'Token saved.' })
150
+ refresh()
151
+ } catch (error) {
152
+ setMessage({ ok: false, text: `Could not save the token: ${error && error.message ? error.message : String(error)}` })
153
+ } finally {
154
+ setBusy(false)
155
+ }
156
+ }
157
+ const status = credential.loading ? 'Checking…'
158
+ : credential.configured ? `Configured${credential.source ? ` (from ${credential.source})` : ''}` : 'Not configured'
159
+ return h('section', { style: css.section },
160
+ h('h4', { style: css.heading }, 'GitHub token'),
161
+ h('p', { style: css.intro },
162
+ 'The plugin publishes with a GitHub token stored in dsh’s credential store as ',
163
+ h('code', { style: css.code }, tokenEnv), '. It never enters the conversation. ',
164
+ h('a', { href: TOKEN_HELP, target: '_blank', rel: 'noopener noreferrer', style: css.link }, 'Create a fine-grained token'),
165
+ ' with Contents: read and write (and Pages: read) on your artifacts repository. The per-artifact strategy needs access to all repositories with Administration, Contents, and Pages write.'),
166
+ h('div', { style: css.row },
167
+ h('span', { style: credential.configured ? css.ok : css.warn }, status)),
168
+ credential.unavailable
169
+ ? h('span', { style: css.hint }, 'This connection cannot write credentials; set the token in the environment or $DSH_HOME/.credentials.yaml.')
170
+ : !credential.writable
171
+ ? h('span', { style: css.hint }, 'This token comes from the environment dsh was started with, so it cannot be changed here.')
172
+ : h('div', { style: css.row },
173
+ h('div', { style: { flex: '1 1 260px', display: 'flex' } },
174
+ h('input', {
175
+ type: 'password', value: draft, placeholder: credential.configured ? 'Paste a new token to replace it' : 'github_pat_…',
176
+ autoComplete: 'off', spellCheck: false, style: { ...css.input, flex: 1 },
177
+ onChange: event => setDraft(event.target.value),
178
+ onKeyDown: (event) => { if (event.key === 'Enter') void save() },
179
+ })),
180
+ h('button', { type: 'button', style: { ...css.primary, opacity: busy || draft.trim() === '' ? 0.6 : 1 }, disabled: busy || draft.trim() === '', onClick: save },
181
+ credential.configured ? 'Replace token' : 'Save token')),
182
+ message ? h('span', { style: message.ok ? css.ok : css.error }, message.text) : null)
183
+ }
184
+
185
+ /** The Plugins page asks for a one-line summary or the full page. */
186
+ function ConfigEntry(props) {
187
+ return props.view === 'summary' ? 'Publish HTML pages and Markdown documents to GitHub Pages.' : h(ConfigPage, props)
188
+ }
189
+
190
+ function ConfigPage({ ctx }) {
191
+ const form = useMemo(() => ctx.configForms.get(NS), [ctx])
192
+ const snapshot = useForm(form)
193
+ const [draft, setDraft] = useState({})
194
+ const [message, setMessage] = useState(null)
195
+ const [busy, setBusy] = useState(false)
196
+ if (snapshot.status === 'loading') return h('div', { style: css.page }, h('p', { style: css.intro }, 'Loading settings…'))
197
+ if (snapshot.status === 'unavailable' || snapshot.value === undefined) {
198
+ return h('div', { style: css.page }, h('p', { style: css.intro }, 'Settings are not available on this connection. Edit the gh-pages-artifacts row in your profile’s cordis.patch.yml instead.'))
199
+ }
200
+ const value = snapshot.value
201
+ const read = (name) => (name in draft ? draft[name] : at(value, FIELDS[name].path))
202
+ const edit = (name, next) => { setMessage(null); setDraft(previous => ({ ...previous, [name]: next })) }
203
+ const overridden = name => present(snapshot.user, FIELDS[name].path)
204
+ const disabled = !snapshot.writable || busy
205
+ const dirty = Object.keys(draft).length > 0
206
+ const resetField = async (name) => {
207
+ setBusy(true)
208
+ try {
209
+ const field = FIELDS[name]
210
+ const ok = await form.mutate([{ op: 'unset', path: field.resetPath || field.path }], snapshot.revision)
211
+ setDraft(previous => {
212
+ const next = { ...previous }
213
+ for (const other of Object.keys(FIELDS)) {
214
+ if (other === name || (field.resetPath && FIELDS[other].resetPath === field.resetPath)) delete next[other]
215
+ }
216
+ return next
217
+ })
218
+ setMessage(ok ? { ok: true, text: 'Reset to default.' } : { ok: false, text: 'The change was not accepted; the page shows the current values.' })
219
+ } finally {
220
+ setBusy(false)
221
+ }
222
+ }
223
+ const save = async () => {
224
+ const name = String(read('authorName') ?? '').trim()
225
+ const email = String(read('authorEmail') ?? '').trim()
226
+ if ((name === '') !== (email === '')) {
227
+ setMessage({ ok: false, text: 'Set both the commit author name and email, or neither.' })
228
+ return
229
+ }
230
+ const ops = []
231
+ for (const [field, next] of Object.entries(draft)) {
232
+ const { path, optional } = FIELDS[field]
233
+ const text = typeof next === 'string' ? next.trim() : next
234
+ if (optional && text === '') ops.push({ op: 'unset', path })
235
+ else ops.push({ op: 'set', path, value: text })
236
+ }
237
+ setBusy(true)
238
+ setMessage(null)
239
+ try {
240
+ const ok = await form.mutate(ops, snapshot.revision)
241
+ if (ok) {
242
+ setDraft({})
243
+ setMessage({ ok: true, text: 'Saved. New settings apply to the next publish.' })
244
+ } else {
245
+ setMessage({ ok: false, text: 'The settings were not accepted (a value may be invalid, or the file changed meanwhile). The page shows the current values.' })
246
+ setDraft({})
247
+ }
248
+ } catch (error) {
249
+ setMessage({ ok: false, text: `Could not save: ${error && error.message ? error.message : String(error)}` })
250
+ } finally {
251
+ setBusy(false)
252
+ }
253
+ }
254
+ const strategy = read('repoStrategy')
255
+ const tokenEnv = String(at(value, ['tokenEnv']) || DEFAULT_TOKEN_ENV)
256
+ return h('div', { style: css.page },
257
+ h(TokenSection, { ctx, tokenEnv }),
258
+ h('section', { style: css.section },
259
+ h('h4', { style: css.heading }, 'Where artifacts are published'),
260
+ h('p', { style: css.intro }, 'You can also change this in chat, for example “use github.com/me/team-pages for artifacts” or “put each artifact in its own repo”; the agent saves the choice here. Existing artifacts stay where they were published.'),
261
+ h(Field, { label: 'Repository strategy', overridden: overridden('repoStrategy'), onReset: () => resetField('repoStrategy') },
262
+ h('div', { style: css.radios },
263
+ [['shared', 'One shared repository', 'Every artifact gets its own folder in one repository.'],
264
+ ['per-artifact', 'A new repository for each artifact', 'Each new artifact gets its own repository and GitHub Pages site.']].map(([option, title, text]) =>
265
+ h('label', { key: option, style: css.choice },
266
+ h('input', { type: 'radio', name: 'gh-pages-artifacts-strategy', value: option, checked: strategy === option, disabled, onChange: () => edit('repoStrategy', option) }),
267
+ h('span', null, h('span', { style: css.label }, title), h('br'), h('span', { style: css.hint }, text)))))),
268
+ strategy === 'per-artifact'
269
+ ? h('div', { style: css.two },
270
+ h(Field, { label: 'Owner of new repositories', hint: 'Your account or an organization; defaults to the token’s account.', overridden: overridden('owner'), onReset: () => resetField('owner') },
271
+ h(TextInput, { value: read('owner'), placeholder: 'token’s account', disabled, onChange: next => edit('owner', next) })),
272
+ h(Field, { label: 'Repository name prefix', hint: 'New repositories are named <prefix><artifact id>.', overridden: overridden('repoPrefix'), onReset: () => resetField('repoPrefix') },
273
+ h(TextInput, { value: read('repoPrefix'), disabled, onChange: next => edit('repoPrefix', next) })),
274
+ h(Field, { label: 'Visibility of new repositories', hint: 'Pages on private repositories needs a paid plan; pages are public either way.', overridden: overridden('repoVisibility'), onReset: () => resetField('repoVisibility') },
275
+ h(Select, { value: read('repoVisibility'), options: [['public', 'Public'], ['private', 'Private']], disabled, onChange: next => edit('repoVisibility', next) })))
276
+ : h(Field, { label: 'Shared repository', hint: 'owner/name or a remote URL such as https://github.com/owner/name.git or git@github.com:owner/name.git. Leave empty to use the owner and name below.', overridden: overridden('repository'), onReset: () => resetField('repository') },
277
+ h(TextInput, { value: read('repository'), placeholder: 'owner/name or https://github.com/owner/name.git', disabled, onChange: next => edit('repository', next) })),
278
+ strategy === 'per-artifact' ? null : h('div', { style: css.two },
279
+ h(Field, { label: 'Owner', hint: 'Used when no shared repository is set; defaults to the token’s account.', overridden: overridden('owner'), onReset: () => resetField('owner') },
280
+ h(TextInput, { value: read('owner'), placeholder: 'token’s account', disabled, onChange: next => edit('owner', next) })),
281
+ h(Field, { label: 'Repository name', hint: 'Used when no shared repository is set.', overridden: overridden('repo'), onReset: () => resetField('repo') },
282
+ h(TextInput, { value: read('repo'), disabled, onChange: next => edit('repo', next) }))),
283
+ h(Field, { label: 'Pages branch', hint: 'GitHub Pages publishes this branch; it is created when missing.', overridden: overridden('branch'), onReset: () => resetField('branch') },
284
+ h(TextInput, { value: read('branch'), disabled, onChange: next => edit('branch', next) }))),
285
+ h('section', { style: css.section },
286
+ h('h4', { style: css.heading }, 'Commits'),
287
+ h('div', { style: css.two },
288
+ h(Field, { label: 'Author name', hint: 'Leave both empty to use the token’s account.', overridden: overridden('authorName'), onReset: () => resetField('authorName') },
289
+ h(TextInput, { value: read('authorName'), placeholder: 'Your Name', disabled, onChange: next => edit('authorName', next) })),
290
+ h(Field, { label: 'Author email', overridden: overridden('authorEmail'), onReset: () => resetField('authorEmail') },
291
+ h(TextInput, { value: read('authorEmail'), placeholder: 'you@example.com', disabled, onChange: next => edit('authorEmail', next) })))),
292
+ h('section', { style: css.section },
293
+ h('h4', { style: css.heading }, 'Safety'),
294
+ h(Field, { label: 'Ask before publishing and deleting', overridden: overridden('approval'), onReset: () => resetField('approval') },
295
+ h(Select, {
296
+ value: read('approval'), disabled, onChange: next => edit('approval', next),
297
+ options: [['unless-full-access', 'Always, except in Full access sessions'], ['always', 'Always'], ['off', 'Never']],
298
+ })),
299
+ h(Toggle, { label: 'Ask search engines not to index published pages', checked: read('noindex'), disabled, onChange: next => edit('noindex', next) }),
300
+ h(Toggle, { label: 'Refuse to publish credential files and text that looks like a secret', checked: read('blockSecrets'), disabled, onChange: next => edit('blockSecrets', next) })),
301
+ h('section', { style: css.section },
302
+ h('h4', { style: css.heading }, 'Advanced'),
303
+ h('div', { style: css.two },
304
+ h(Field, { label: 'Token credential name', hint: 'Environment variable / credential reference holding the token.', overridden: overridden('tokenEnv'), onReset: () => resetField('tokenEnv') },
305
+ h(TextInput, { value: read('tokenEnv'), disabled, onChange: next => edit('tokenEnv', next) })),
306
+ h(Field, { label: 'Site URL (custom domain)', hint: 'Only needed for a custom domain the token cannot read from GitHub.', overridden: overridden('baseUrl'), onReset: () => resetField('baseUrl') },
307
+ h(TextInput, { value: read('baseUrl'), placeholder: 'https://pages.example.com', disabled, onChange: next => edit('baseUrl', next) })))),
308
+ h('div', { style: css.actions },
309
+ h('button', { type: 'button', style: { ...css.primary, opacity: dirty && !disabled ? 1 : 0.6 }, disabled: !dirty || disabled, onClick: save }, busy ? 'Saving…' : 'Save'),
310
+ h('button', { type: 'button', style: { ...css.secondary, opacity: dirty ? 1 : 0.6 }, disabled: !dirty || busy, onClick: () => { setDraft({}); setMessage(null) } }, 'Discard'),
311
+ snapshot.writable ? null : h('span', { style: css.hint }, 'These settings are fixed by a higher configuration layer and cannot be edited here.'),
312
+ message ? h('span', { style: message.ok ? css.ok : css.error }, message.text) : null),
313
+ h('p', { style: css.hint }, 'Everything published is listed under Artifacts in the sidebar.'))
314
+ }
315
+
316
+ // ---- Artifacts sidebar entry and panel -------------------------------------------------
317
+
318
+ /** One id for the sidebar entry and its main panel; namespaced so it never collides with a built-in. */
319
+ const PANEL_ID = 'dsh-gh-pages-artifacts'
320
+ /** Plugins is order 0 and Automation tasks 10; the entry sorts after both. */
321
+ const PANEL_ORDER = 20
322
+ /** Document-relative routes the Host half serves (never '/api/...': the page may be mounted under a prefix). */
323
+ const LIST_ROUTE = 'api/gh-pages-artifacts/list'
324
+ const REFRESH_ROUTE = 'api/gh-pages-artifacts/refresh'
325
+ const DELETE_ROUTE = 'api/gh-pages-artifacts/delete'
326
+
327
+ /** Sidebar glyph: the sidebar passes { size, active } and draws the button around it. */
328
+ function ArtifactsIcon({ size, className }) {
329
+ return h('svg', {
330
+ width: size, height: size, className, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true',
331
+ stroke: 'currentColor', strokeWidth: 1, strokeLinejoin: 'round', strokeLinecap: 'round',
332
+ },
333
+ h('path', { d: 'M8 2.5 13.5 5.25 8 8 2.5 5.25Z' }),
334
+ h('path', { d: 'M2.5 8 8 10.75 13.5 8' }),
335
+ h('path', { d: 'M2.5 10.75 8 13.5 13.5 10.75' }))
336
+ }
337
+
338
+ function TrashIcon() {
339
+ return h('svg', {
340
+ width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true',
341
+ stroke: 'currentColor', strokeWidth: 1, strokeLinejoin: 'round', strokeLinecap: 'round',
342
+ },
343
+ h('path', { d: 'M2.5 4.5h11' }),
344
+ h('path', { d: 'M6 4.5V3a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v1.5' }),
345
+ h('path', { d: 'M4 4.5l.6 8.1a1 1 0 0 0 1 .9h4.8a1 1 0 0 0 1-.9l.6-8.1' }),
346
+ h('path', { d: 'M6.75 7v4M9.25 7v4' }))
347
+ }
348
+
349
+ /** The host's list-page frame (Automation tasks / Plugins), as literal values and theme tokens. */
350
+ const PANEL_CSS = `
351
+ .gpa-page{display:flex;flex-direction:column;width:100%;height:100%;min-width:0;min-height:0;overflow:hidden;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);font-size:14px;line-height:1.6}
352
+ .gpa-scroll{flex:1;min-height:0;overflow:auto;scrollbar-gutter:stable;--dsh-scrollbar-width:9px;--dsh-scrollbar-thumb-border:2px}
353
+ .gpa-content{max-width:960px;margin:0 auto;padding:0 clamp(24px,4vw,48px) 48px}
354
+ .gpa-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-top:28px;margin-bottom:8px}
355
+ html[data-platform='darwin'] .gpa-head{padding-top:calc(28px + var(--dsh-frame-top-clearance, 0px))}
356
+ .gpa-head h1{flex:1;min-width:0;margin:0;font-size:20px;line-height:28px;font-weight:500}
357
+ .gpa-intro{margin:0 0 20px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
358
+ .gpa-button{height:32px;padding:0 12px;border-radius:16px;border:0.5px solid var(--dsw-alias-border-l3);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;cursor:pointer}
359
+ .gpa-button:hover{background:var(--dsw-alias-interactive-bg-hover)}
360
+ .gpa-button:disabled{opacity:.6;cursor:default}
361
+ .gpa-rows{display:flex;flex-direction:column;gap:2px;margin:0 -8px;padding:0;list-style:none}
362
+ .gpa-item{display:flex;flex-direction:column;border-radius:12px}
363
+ .gpa-item:hover,.gpa-item.gpa-confirming{background:var(--dsw-alias-interactive-bg-hover)}
364
+ .gpa-main{display:flex;align-items:flex-start;gap:4px}
365
+ .gpa-row{display:flex;flex:1;min-width:0;align-items:flex-start;gap:12px;padding:8px;border-radius:12px;color:inherit;text-decoration:none}
366
+ .gpa-actions{flex:none;padding:8px 8px 0 0}
367
+ .gpa-icon-button{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-caption);cursor:pointer;opacity:0}
368
+ .gpa-item:hover .gpa-icon-button,.gpa-icon-button:focus-visible{opacity:1}
369
+ @media (hover:none){.gpa-icon-button{opacity:1}}
370
+ .gpa-icon-button:hover{background:var(--dsw-alias-interactive-bg-hover-danger, var(--dsw-alias-interactive-bg-hover));color:var(--dsw-alias-state-error-primary)}
371
+ .gpa-icon-button:focus-visible,.gpa-button:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));outline-offset:-2px}
372
+ .gpa-confirm{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:0 8px 8px 36px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
373
+ .gpa-confirm-text{flex:1;min-width:200px}
374
+ .gpa-danger{height:28px;padding:0 12px;border:0;border-radius:14px;background:var(--dsw-alias-state-error-primary);color:#fff;font:inherit;font-size:13px;cursor:pointer}
375
+ .gpa-danger:disabled{opacity:.6;cursor:default}
376
+ .gpa-small{height:28px;border-radius:14px}
377
+ .gpa-row:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));outline-offset:-2px}
378
+ .gpa-glyph{flex:none;width:16px;height:20px;margin-top:2px;color:var(--dsw-alias-label-tertiary)}
379
+ .gpa-body{display:flex;flex:1;flex-direction:column;min-width:0}
380
+ .gpa-title{overflow:hidden;font-weight:500;line-height:23px;text-overflow:ellipsis;white-space:nowrap}
381
+ .gpa-link{color:var(--dsw-alias-link);font-size:13px;line-height:21px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
382
+ .gpa-summary{display:block;margin-top:2px;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:21px;overflow-wrap:anywhere}
383
+ .gpa-meta+.gpa-meta::before{content:'·';padding:0 2px}
384
+ .gpa-empty{display:flex;flex-direction:column;align-items:center;padding:48px 20px;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:14px}
385
+ .gpa-notice{margin:0 0 16px;padding:12px 14px;border-radius:10px;background:var(--dsw-specific-sidebar-fill);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:20px}
386
+ .gpa-error{color:var(--dsw-alias-state-error-primary)}
387
+ `
388
+
389
+ /** One <style> owned by this package, removed when the panel registration goes away. */
390
+ function installStyles() {
391
+ if (typeof document === 'undefined') return () => {}
392
+ const element = document.createElement('style')
393
+ element.setAttribute('data-plugin', PKG)
394
+ element.textContent = PANEL_CSS
395
+ document.head.appendChild(element)
396
+ return () => { element.remove() }
397
+ }
398
+
399
+ /** Registry data is not trusted markup: only http(s) URLs become links. */
400
+ function safeHref(url) {
401
+ try {
402
+ const parsed = new URL(String(url))
403
+ return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : undefined
404
+ } catch {
405
+ return undefined
406
+ }
407
+ }
408
+
409
+ async function requestArtifacts(route, method, signal, payload) {
410
+ const response = await fetch(route, {
411
+ method, signal,
412
+ headers: payload === undefined ? { accept: 'application/json' } : { accept: 'application/json', 'content-type': 'application/json' },
413
+ ...payload === undefined ? {} : { body: JSON.stringify(payload) },
414
+ })
415
+ if (response.status === 401) throw new Error('This browser is no longer signed in to dsh. Reopen dsh from the link it printed.')
416
+ if (response.status === 404) throw new Error('The artifact list is not available. Is the GitHub Pages Artifacts plugin running?')
417
+ const body = await response.json().catch(() => null)
418
+ if (!response.ok) throw new Error(body && body.error ? body.error : `HTTP ${response.status}`)
419
+ return body
420
+ }
421
+
422
+ /** Loads the list when the panel opens and after a reconnect; refresh() also checks GitHub. */
423
+ function useArtifacts(ctx) {
424
+ const [state, setState] = useState({ status: 'loading', artifacts: [], notes: [], error: null })
425
+ const [generation, setGeneration] = useState(0)
426
+ useEffect(() => ctx.on('connection/reset', () => setGeneration(value => value + 1)), [ctx])
427
+ useEffect(() => {
428
+ const abort = new AbortController()
429
+ requestArtifacts(LIST_ROUTE, 'GET', abort.signal).then(
430
+ body => setState({ status: 'ready', artifacts: body.artifacts || [], notes: body.notes || [], error: null }),
431
+ (error) => { if (!abort.signal.aborted) setState(previous => ({ ...previous, status: 'error', error: String((error && error.message) || error) })) })
432
+ return () => abort.abort()
433
+ }, [generation])
434
+ const refresh = async () => {
435
+ setState(previous => ({ ...previous, status: 'refreshing', error: null }))
436
+ try {
437
+ const body = await requestArtifacts(REFRESH_ROUTE, 'POST')
438
+ setState({ status: 'ready', artifacts: body.artifacts || [], notes: body.notes || [], error: null })
439
+ } catch (error) {
440
+ setState(previous => ({ ...previous, status: 'error', error: String((error && error.message) || error) }))
441
+ }
442
+ }
443
+ /** Delete after the user confirmed; the Host answers with the remaining list. */
444
+ const remove = async (id) => {
445
+ const body = await requestArtifacts(DELETE_ROUTE, 'POST', undefined, { id })
446
+ setState(previous => ({ ...previous, status: 'ready', artifacts: body.artifacts || [], error: null }))
447
+ return body.deleted
448
+ }
449
+ return [state, refresh, remove]
450
+ }
451
+
452
+ function formatTime(value) {
453
+ const time = Date.parse(value)
454
+ return Number.isNaN(time) ? '' : new Date(time).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
455
+ }
456
+
457
+ /** The main-column page; mounted only while selected, so opening it loads the list again. */
458
+ function ArtifactsPage({ ctx }) {
459
+ const [state, refresh, remove] = useArtifacts(ctx)
460
+ const [confirming, setConfirming] = useState(null)
461
+ const [deleting, setDeleting] = useState(null)
462
+ const [flash, setFlash] = useState(null)
463
+ const rows = state.artifacts.filter(item => item && item.status !== 'deleted')
464
+ const busy = state.status === 'loading' || state.status === 'refreshing' || deleting !== null
465
+ const confirmDelete = async (item) => {
466
+ setDeleting(item.id)
467
+ setFlash(null)
468
+ try {
469
+ const result = await remove(item.id)
470
+ const notes = result && Array.isArray(result.notes) ? result.notes : []
471
+ setFlash({ ok: true, text: [`Deleted \u201c${item.title || item.id}\u201d. Its link stops working within a few minutes.`, ...notes].join(' ') })
472
+ setConfirming(null)
473
+ } catch (error) {
474
+ setFlash({ ok: false, text: `Could not delete \u201c${item.title || item.id}\u201d: ${String((error && error.message) || error)}` })
475
+ } finally {
476
+ setDeleting(null)
477
+ }
478
+ }
479
+ return h('section', { className: 'gpa-page', 'aria-label': 'Artifacts' },
480
+ h('div', { className: 'gpa-scroll' },
481
+ h('div', { className: 'gpa-content' },
482
+ h('div', { className: 'gpa-head', 'data-window-drag': '' },
483
+ h('h1', null, 'Artifacts'),
484
+ h('button', { type: 'button', className: 'gpa-button', disabled: busy, onClick: refresh, title: 'Also look on GitHub for artifacts published elsewhere' },
485
+ state.status === 'refreshing' ? 'Refreshing…' : 'Refresh')),
486
+ h('p', { className: 'gpa-intro' }, rows.length === 0 ? 'Pages and documents the agent publishes to GitHub Pages.'
487
+ : `${rows.length} published page${rows.length === 1 ? '' : 's'}. Links open in a new tab.`),
488
+ state.status === 'error' ? h('p', { className: 'gpa-notice gpa-error', role: 'alert' }, `Could not load artifacts: ${state.error}`) : null,
489
+ flash ? h('p', { className: flash.ok ? 'gpa-notice' : 'gpa-notice gpa-error', role: flash.ok ? 'status' : 'alert' }, flash.text) : null,
490
+ state.notes.map(note => h('p', { key: note, className: 'gpa-notice' }, note)),
491
+ state.status === 'loading' && rows.length === 0 ? h('div', { className: 'gpa-empty', role: 'status' }, 'Loading…') : null,
492
+ state.status === 'ready' && rows.length === 0
493
+ ? h('div', { className: 'gpa-empty', role: 'status' }, 'Nothing published yet. Ask the agent to make a page and publish it.')
494
+ : null,
495
+ rows.length > 0 ? h('ul', { className: 'gpa-rows', 'aria-label': 'Published artifacts', 'aria-busy': busy },
496
+ rows.map((item) => {
497
+ const href = safeHref(item.url)
498
+ const meta = [
499
+ item.kind === 'markdown' ? 'Document' : 'Page',
500
+ item.repository,
501
+ item.rev > 1 ? `rev ${item.rev}` : undefined,
502
+ item.updatedAt ? `updated ${formatTime(item.updatedAt)}` : undefined,
503
+ ].filter(Boolean)
504
+ const body = [
505
+ h(ArtifactsIcon, { key: 'glyph', size: 16, className: 'gpa-glyph' }),
506
+ h('span', { key: 'body', className: 'gpa-body' },
507
+ h('span', { className: 'gpa-title' }, item.title || item.id),
508
+ href ? h('span', { className: 'gpa-link' }, href) : null,
509
+ item.description ? h('span', { className: 'gpa-summary' }, item.description) : null,
510
+ h('span', { className: 'gpa-summary' }, meta.map(text => h('span', { key: text, className: 'gpa-meta' }, text)))),
511
+ ]
512
+ const title = item.title || item.id
513
+ const isConfirming = confirming === item.id
514
+ const isDeleting = deleting === item.id
515
+ return h('li', { key: item.id, className: isConfirming ? 'gpa-item gpa-confirming' : 'gpa-item' },
516
+ h('div', { className: 'gpa-main' },
517
+ href
518
+ ? h('a', { className: 'gpa-row', href, target: '_blank', rel: 'noopener noreferrer' }, ...body)
519
+ : h('div', { className: 'gpa-row' }, ...body),
520
+ isConfirming ? null : h('div', { className: 'gpa-actions' },
521
+ h('button', {
522
+ type: 'button', className: 'gpa-icon-button', disabled: busy,
523
+ title: 'Delete', 'aria-label': `Delete ${title}`,
524
+ onClick: () => { setFlash(null); setConfirming(item.id) },
525
+ }, h(TrashIcon)))),
526
+ isConfirming ? h('div', { className: 'gpa-confirm', role: 'group', 'aria-label': `Confirm deleting ${title}` },
527
+ h('span', { className: 'gpa-confirm-text' },
528
+ `Delete this page from ${item.repository || 'GitHub Pages'}? The link stops working for everyone; the repository history keeps a copy.`),
529
+ h('button', { type: 'button', className: 'gpa-danger', disabled: isDeleting, onClick: () => confirmDelete(item) },
530
+ isDeleting ? 'Deleting\u2026' : 'Delete'),
531
+ h('button', { type: 'button', className: 'gpa-button gpa-small', disabled: isDeleting, onClick: () => setConfirming(null) }, 'Cancel'))
532
+ : null)
533
+ })) : null)))
534
+ }
535
+
536
+ /** Register the sidebar entry and its main panel; returns one disposer. */
537
+ function registerArtifactsPanel(ctx) {
538
+ const Page = props => h(ArtifactsPage, { ...props, ctx })
539
+ const offStyles = installStyles()
540
+ // The main entry first: selecting a sidebar entry needs a 'main' entry with the same key.
541
+ const offMain = ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID }, Page))
542
+ // The sidebar draws the button, label, tooltip, and active state; the component is just the glyph.
543
+ const offEntry = ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
544
+ name: 'sidebar.panellist', id: PANEL_ID, order: PANEL_ORDER, label: 'Artifacts',
545
+ }, ArtifactsIcon))
546
+ return () => { offEntry(); offMain(); offStyles() }
547
+ }
548
+
549
+ return {
550
+ inject: ['slots', 'configForms', 'remote', 'remote.credentials'],
551
+ apply(ctx) {
552
+ const Page = props => h(ConfigEntry, { ...props, ctx })
553
+ // The page exists while the Host serves this plugin's settings, i.e. while its row runs.
554
+ ctx.effect(() => ctx.configForms.whileServed([NS], () => {
555
+ const offBundle = ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ name: 'plugins.bundle.config', key: PKG }, Page))
556
+ const offRow = ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ name: 'plugins.row.config', key: `${PKG}#${NS}` }, Page))
557
+ // The sidebar entry lives exactly as long as the plugin's row runs, like the settings page.
558
+ const offPanel = registerArtifactsPanel(ctx)
559
+ return () => { offPanel(); offRow(); offBundle() }
560
+ }), 'gh-pages-artifacts: settings page and artifacts panel')
561
+ },
562
+ }
563
+ },
564
+ })
@@ -0,0 +1,15 @@
1
+ # Bundle layer for dsh-gh-pages-artifacts.
2
+ #
3
+ # The row carries no `config`, so every option uses its schema default.
4
+ # Override it from your profile's cordis.patch.yml by id, for example:
5
+ #
6
+ # - id: gh-pages-artifacts
7
+ # config:
8
+ # owner: your-github-login
9
+ # repo: dsh-artifacts
10
+ #
11
+ # A patch replaces the row's whole `config`; keys you leave out fall back to
12
+ # their defaults.
13
+ - insert:
14
+ - id: gh-pages-artifacts
15
+ name: dsh-gh-pages-artifacts
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="GitHub Pages Artifacts">
2
+ <rect x="6" y="6" width="52" height="52" rx="12" fill="#0969da"/>
3
+ <path d="M20 18h17l9 9v19a3 3 0 0 1-3 3H20a3 3 0 0 1-3-3V21a3 3 0 0 1 3-3z" fill="#fff"/>
4
+ <path d="M37 18v7a2 2 0 0 0 2 2h7" fill="#cfe3ff"/>
5
+ <path d="M24 33h16M24 39h11" stroke="#0969da" stroke-width="3" stroke-linecap="round"/>
6
+ </svg>
@@ -0,0 +1,26 @@
1
+ /** Approval gate for outward-facing artifact changes. */
2
+ import type { Context } from '@deepseek-ai/cordis';
3
+ import type { ToolRunContext } from '@deepseek-ai/dsh-tools';
4
+ import type { ApprovalMode } from './config.js';
5
+ /** What the user is asked to approve. */
6
+ export interface ApprovalAsk {
7
+ /** Tool name shown with the request. */
8
+ readonly toolName: string;
9
+ /** Audit text persisted with the request. */
10
+ readonly reason: string;
11
+ /** Text shown in the approval panel. */
12
+ readonly display: string;
13
+ }
14
+ /**
15
+ * Ask the user before a publish, update, or delete, following the configured mode. With
16
+ * 'unless-full-access' the prompt is skipped only in sessions that run with the danger-full-access
17
+ * sandbox and never prompt for approval (the "Full access" preset). The gate runs
18
+ * inside the tool body, after arguments and the target URL are known, so the prompt names the
19
+ * exact URL. Only an explicit 'allowed-once' lets the change through.
20
+ * @param ctx - plugin context.
21
+ * @param mode - configured approval mode.
22
+ * @param exec - tool execution.
23
+ * @param ask - request text.
24
+ * @throws Error with a model-facing explanation when approval is not granted.
25
+ */
26
+ export declare function requireApproval(ctx: Context, mode: ApprovalMode, exec: ToolRunContext, ask: ApprovalAsk): Promise<void>;