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.
package/lib/service.js ADDED
@@ -0,0 +1,1216 @@
1
+ /**
2
+ * Artifact operations: token and site discovery, repository strategies, publish, list, read,
3
+ * delete, status, and repository management. Every artifact is tracked in the local registry,
4
+ * which records where it lives, so artifacts spread over many repositories stay reachable.
5
+ */
6
+ import { createHash, randomInt } from 'node:crypto';
7
+ import { readFileSync } from 'node:fs';
8
+ import { access } from 'node:fs/promises';
9
+ import { credentialRef } from '@deepseek-ai/dsh-credentials';
10
+ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
11
+ import { requireApproval } from './approval.js';
12
+ import { parseRepository } from './config.js';
13
+ import { baseName, credentialPathProblem, findSecrets, formatBytes, isTextName, readWorkspaceFile } from './content.js';
14
+ import { GitHubApiError, GitHubClient } from './github.js';
15
+ import { assetNameProblem, idFromTitle, idProblem, PAGE_FILE, SOURCE_FILE, } from './manifest.js';
16
+ import { ALL_TOOLS, DELETE_TOOL, MUTATING_TOOLS, PUBLISH_TOOL } from './names.js';
17
+ import { Registry, repositoryName } from './registry.js';
18
+ import { prepareHtmlPage, renderMarkdownPage, stripInjected } from './render.js';
19
+ import { ArtifactStore, decodeUtf8, jekyllSiteError, joinPath } from './store.js';
20
+ import { hiddenTools } from './visibility.js';
21
+ /** Package version, sent in the User-Agent. */
22
+ export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
23
+ const SITE_CACHE_MS = 5 * 60 * 1000;
24
+ const DEPLOY_WAIT_MS = 5 * 60 * 1000;
25
+ const READ_DEFAULT_CHARS = 8000;
26
+ const READ_MAX_CHARS = 20000;
27
+ const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567';
28
+ const MAX_TITLE = 200;
29
+ const MAX_DESCRIPTION = 500;
30
+ const GITHUB_API = 'https://api.github.com';
31
+ const REPO_NAME = /^[A-Za-z0-9._-]{1,100}$/;
32
+ /** Topic set on repositories created per artifact, so they are easy to find on GitHub. */
33
+ export const ARTIFACT_TOPIC = 'dsh-artifact';
34
+ /** Owns configuration-derived state shared by the tools. */
35
+ export class ArtifactsRuntime {
36
+ ctx;
37
+ hooks;
38
+ /** Local record of everything published, and the user's runtime choices. */
39
+ registry;
40
+ writeQueue = Promise.resolve();
41
+ logins = new Map();
42
+ sites = new Map();
43
+ readSettings;
44
+ /** Loader entry id of this plugin row, which names its settings namespace. */
45
+ namespace;
46
+ /**
47
+ * @param ctx - plugin context.
48
+ * @param settings - settings, or a reader that returns the current ones; options edited on the
49
+ * plugin's settings page change while the plugin runs, so they are read per operation.
50
+ * @param hooks - test hooks.
51
+ */
52
+ constructor(ctx, settings, hooks = {}, namespace) {
53
+ this.ctx = ctx;
54
+ this.hooks = hooks;
55
+ this.readSettings = typeof settings === 'function' ? settings : () => settings;
56
+ this.namespace = namespace;
57
+ this.registry = new Registry(this.readSettings().registryDir, message => ctx.logger.warn(message));
58
+ }
59
+ /** The current settings. */
60
+ get settings() {
61
+ return this.readSettings();
62
+ }
63
+ now() {
64
+ return this.hooks.now?.() ?? new Date();
65
+ }
66
+ randomSuffix() {
67
+ if (this.hooks.randomSuffix !== undefined)
68
+ return this.hooks.randomSuffix();
69
+ let suffix = '';
70
+ for (let index = 0; index < 6; index++)
71
+ suffix += BASE32[randomInt(BASE32.length)];
72
+ return suffix;
73
+ }
74
+ /**
75
+ * Resolve the GitHub token for one operation; never cached.
76
+ * @returns the token value, or undefined when not configured.
77
+ */
78
+ async resolveToken() {
79
+ const ref = credentialRef(this.settings.tokenEnv);
80
+ const credentials = this.ctx.get('credentials');
81
+ if (credentials !== undefined)
82
+ return await credentials.resolve(ref);
83
+ const ambient = launchEnvironmentOf(this.ctx).get(ref);
84
+ return ambient !== undefined && ambient.value.length > 0 ? { value: ambient.value, source: ambient.source } : undefined;
85
+ }
86
+ missingTokenError() {
87
+ return new Error(`No GitHub token is configured. Ask the user to create a GitHub token and provide it as the ${this.settings.tokenEnv} credential (environment variable, $DSH_HOME/.credentials.yaml, or $DSH_HOME/.env); see the dsh-gh-pages-artifacts README. Never ask the user to paste the token into the chat.`);
88
+ }
89
+ async api() {
90
+ const token = await this.resolveToken();
91
+ if (token === undefined)
92
+ throw this.missingTokenError();
93
+ const gh = new GitHubClient({
94
+ apiBaseUrl: this.settings.apiBaseUrl,
95
+ token: token.value,
96
+ userAgent: `dsh-gh-pages-artifacts/${VERSION}`,
97
+ ...this.hooks.fetch === undefined ? {} : { fetch: this.hooks.fetch },
98
+ ...this.hooks.sleep === undefined ? {} : { sleep: this.hooks.sleep },
99
+ });
100
+ return { gh, token: token.value, tokenSource: token.source };
101
+ }
102
+ async login(api, signal) {
103
+ const key = createHash('sha256').update(api.token).digest('hex').slice(0, 16);
104
+ const known = this.logins.get(key);
105
+ if (known !== undefined)
106
+ return known;
107
+ const user = await api.gh.request('GET', '/user', { signal });
108
+ this.logins.set(key, user.data.login);
109
+ return user.data.login;
110
+ }
111
+ /** The token's own account, refused when the token could come from an untrusted project .env. */
112
+ async tokenOwner(api, signal) {
113
+ if (api.tokenSource === 'project-env') {
114
+ // A .env in the launch directory could swap in someone else's token and, with it, the destination.
115
+ throw new Error(`The ${this.settings.tokenEnv} token comes from the .env file of the directory dsh was launched from, so the plugin will not guess the repository owner from it. Ask the user to set the plugin option owner (or link a repository).`);
116
+ }
117
+ return await this.login(api, signal);
118
+ }
119
+ /** @returns the effective strategy and whether the user changed it at runtime. */
120
+ strategyOf(data) {
121
+ const chosen = data.settings.strategy;
122
+ return chosen === undefined ? { strategy: this.settings.repoStrategy, source: 'config' } : { strategy: chosen, source: 'user' };
123
+ }
124
+ /** The shared repository: a linked one, then the configured one, then `repo` under the token's user. */
125
+ async sharedRepository(api, data, signal) {
126
+ const link = data.settings.link;
127
+ if (link !== undefined)
128
+ return { ref: { owner: link.owner, repo: link.repo }, source: 'linked' };
129
+ if (this.settings.repository !== undefined)
130
+ return { ref: this.settings.repository, source: 'config' };
131
+ if (this.settings.owner !== undefined)
132
+ return { ref: { owner: this.settings.owner, repo: this.settings.repo }, source: 'config' };
133
+ if (api === undefined)
134
+ throw this.missingTokenError();
135
+ return { ref: { owner: await this.tokenOwner(api, signal), repo: this.settings.repo }, source: 'token user' };
136
+ }
137
+ sharedLocation(ref) {
138
+ return { ...ref, branch: this.settings.branch, siteDir: this.settings.siteDir, pathPrefix: this.settings.pathPrefix, layout: 'folder' };
139
+ }
140
+ ownLocation(owner, id) {
141
+ return { owner, repo: `${this.settings.repoPrefix}${id}`, branch: this.settings.branch, siteDir: this.settings.siteDir, pathPrefix: '', layout: 'root' };
142
+ }
143
+ store(api, location, signal) {
144
+ return new ArtifactStore(api.gh, location, {
145
+ author: this.settings.commitAuthor,
146
+ ...this.hooks.sleep === undefined ? {} : { sleep: (ms) => this.hooks.sleep(ms, signal) },
147
+ });
148
+ }
149
+ /**
150
+ * Find where an existing artifact lives: the registry first, then the shared repository's
151
+ * manifest (for artifacts published from another machine).
152
+ */
153
+ async locate(api, data, id, signal) {
154
+ const entry = data.artifacts[id];
155
+ if (entry !== undefined) {
156
+ if (entry.status === 'deleted')
157
+ throw new Error(`Artifact ${id} was deleted`);
158
+ return { location: entry.location, entry };
159
+ }
160
+ const shared = this.sharedLocation((await this.sharedRepository(api, data, signal)).ref);
161
+ const snapshot = await this.store(api, shared, signal).snapshot(signal);
162
+ if (snapshot.manifest.artifacts[id] !== undefined)
163
+ return { location: shared, entry: undefined };
164
+ if (snapshot.manifest.tombstones.includes(id))
165
+ throw new Error(`Artifact ${id} was deleted`);
166
+ throw new Error(`No artifact with id "${id}"; use artifact_list to find ids, or omit id to create a new artifact`);
167
+ }
168
+ /**
169
+ * Discover the public site URL and Pages state of one repository, cached for a few minutes.
170
+ */
171
+ async siteInfo(api, location, signal, fresh = false) {
172
+ const key = `${repositoryName(location)}@${location.branch}`;
173
+ const cached = this.sites.get(key);
174
+ if (!fresh && cached !== undefined && this.now().getTime() - cached.at < SITE_CACHE_MS)
175
+ return cached.info;
176
+ const name = repositoryName(location);
177
+ const onGitHubCom = this.settings.apiBaseUrl === GITHUB_API;
178
+ // baseUrl describes the shared site; repositories created per artifact get their own URL.
179
+ const configured = location.layout === 'folder' ? this.settings.baseUrl : undefined;
180
+ const fallback = configured ?? (onGitHubCom ? defaultSiteUrl(location.owner, location.repo) : undefined);
181
+ const repoPath = `/repos/${encodeURIComponent(location.owner)}/${encodeURIComponent(location.repo)}`;
182
+ const repo = await api.gh.request('GET', repoPath, { allowStatus: [404], signal });
183
+ const isPrivate = repo.status === 200 && repo.data.private === true;
184
+ const privacy = isPrivate && location.layout === 'folder' && location.siteDir === ''
185
+ ? [`${name} is private, but with siteDir '' its whole branch is served, including the artifact index .dsh-artifacts.json that lists every artifact. Set siteDir: docs (and publish Pages from /docs) to keep the index unlisted.`]
186
+ : [];
187
+ const requireUrl = (url) => {
188
+ if (url !== undefined)
189
+ return url;
190
+ throw new Error('Cannot determine the public URL of the Pages site on this GitHub Enterprise server; ask the user to set the plugin option baseUrl, or to grant the token Pages: read.');
191
+ };
192
+ const expectedPath = location.siteDir === '' ? '/' : `/${location.siteDir}`;
193
+ let info;
194
+ try {
195
+ const pages = await api.gh.request('GET', `${repoPath}/pages`, { allowStatus: [404], signal });
196
+ if (pages.status === 404) {
197
+ info = {
198
+ url: requireUrl(fallback), private: isPrivate, pages: 'disabled',
199
+ warnings: [`GitHub Pages is not enabled for ${name}; the plugin tries to enable it on the next publish. If that is not permitted, the user can enable it in Settings → Pages → Deploy from a branch → ${location.branch} / ${expectedPath === '/' ? '(root)' : expectedPath}.`, ...privacy],
200
+ };
201
+ }
202
+ else {
203
+ const sourceBranch = pages.data.source?.branch;
204
+ const sourcePath = pages.data.source?.path;
205
+ const warnings = [...privacy];
206
+ if (pages.data.build_type === 'workflow') {
207
+ warnings.push(`GitHub Pages for ${name} is built by a GitHub Actions workflow, so commits to ${location.branch} may not be served. Switch Pages to "Deploy from a branch" (${location.branch}, ${expectedPath}).`);
208
+ }
209
+ else if (sourceBranch !== undefined && (sourceBranch !== location.branch || (sourcePath ?? '/') !== expectedPath)) {
210
+ warnings.push(`GitHub Pages for ${name} publishes ${sourceBranch} ${sourcePath ?? '/'}, but this plugin writes to ${location.branch} ${expectedPath}. Change the Pages source or the plugin's branch/siteDir options.`);
211
+ }
212
+ const htmlUrl = pages.data.html_url;
213
+ info = {
214
+ url: requireUrl(configured ?? (htmlUrl === undefined ? fallback : normalizeSiteUrl(htmlUrl))),
215
+ private: isPrivate, pages: 'enabled', warnings,
216
+ ...sourceBranch === undefined ? {} : { sourceBranch },
217
+ ...sourcePath === undefined ? {} : { sourcePath },
218
+ };
219
+ }
220
+ }
221
+ catch (error) {
222
+ if (!(error instanceof GitHubApiError) || (error.status !== 403 && error.status !== 401))
223
+ throw error;
224
+ info = { url: requireUrl(fallback), private: isPrivate, pages: 'unknown', warnings: privacy };
225
+ }
226
+ this.sites.set(key, { at: this.now().getTime(), info });
227
+ return info;
228
+ }
229
+ artifactUrl(site, location, id) {
230
+ return location.layout === 'root' ? `${site.url}/` : `${joinUrl(site.url, joinPath(location.pathPrefix, id))}/`;
231
+ }
232
+ /**
233
+ * Turn GitHub Pages on for a repository when it is off. Pushing a gh-pages branch usually does
234
+ * this by itself, so a brand-new repository first gets a few seconds to catch up; otherwise the
235
+ * token needs Pages and Administration write. Never throws: the page is already committed, so a
236
+ * problem here only becomes a warning for the user.
237
+ * @returns warnings for the user when Pages could not be enabled.
238
+ */
239
+ async ensurePages(api, location, signal, newRepository) {
240
+ const repoPath = `/repos/${encodeURIComponent(location.owner)}/${encodeURIComponent(location.repo)}`;
241
+ const how = `Settings → Pages → Deploy from a branch → ${location.branch} / ${location.siteDir === '' ? '(root)' : `/${location.siteDir}`}`;
242
+ const sleep = this.hooks.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
243
+ const enabled = async () => {
244
+ try {
245
+ return (await api.gh.request('GET', `${repoPath}/pages`, { allowStatus: [404], signal })).status === 200;
246
+ }
247
+ catch (error) {
248
+ if (signal.aborted)
249
+ throw error;
250
+ return undefined;
251
+ }
252
+ };
253
+ try {
254
+ let state = await enabled();
255
+ for (let attempt = 0; newRepository && state === false && attempt < 5; attempt++) {
256
+ await sleep(2000, signal);
257
+ state = await enabled();
258
+ }
259
+ if (state === true)
260
+ return [];
261
+ if (state === undefined)
262
+ return [`Cannot check GitHub Pages for ${repositoryName(location)} (the token lacks Pages access); if the page does not appear, enable Pages in ${how}.`];
263
+ let failure;
264
+ try {
265
+ const created = await api.gh.request('POST', `${repoPath}/pages`, {
266
+ body: { build_type: 'legacy', source: { branch: location.branch, path: location.siteDir === '' ? '/' : `/${location.siteDir}` } },
267
+ allowStatus: [403, 404, 409, 422], retry: false, signal,
268
+ });
269
+ if (created.status === 201 || created.status === 409)
270
+ return [];
271
+ failure = created.data?.message ?? `HTTP ${created.status}`;
272
+ }
273
+ catch (error) {
274
+ if (signal.aborted)
275
+ throw error;
276
+ failure = errorText(error);
277
+ }
278
+ // GitHub may have enabled Pages meanwhile (or answered 5xx while doing so).
279
+ if (await enabled() === true)
280
+ return [];
281
+ return [`Could not enable GitHub Pages for ${repositoryName(location)} (${failure}); the user can enable it in ${how}.`];
282
+ }
283
+ finally {
284
+ this.sites.delete(`${repositoryName(location)}@${location.branch}`);
285
+ }
286
+ }
287
+ /** Create the repository of a new per-artifact location. */
288
+ async createRepository(api, location, title, homepage, signal) {
289
+ const login = await this.login(api, signal);
290
+ const ownAccount = location.owner.toLowerCase() === login.toLowerCase();
291
+ if (!ownAccount) {
292
+ const account = await api.gh.request('GET', `/users/${encodeURIComponent(location.owner)}`, { allowStatus: [404], signal });
293
+ if (account.status !== 200 || account.data.type !== 'Organization') {
294
+ throw new Error(`Cannot create repositories under ${location.owner}: it is neither the token's account (${login}) nor an organization`);
295
+ }
296
+ }
297
+ const body = {
298
+ name: location.repo,
299
+ description: oneLine(`${title} (published with dsh-gh-pages-artifacts)`, 300),
300
+ ...homepage === undefined ? {} : { homepage },
301
+ private: this.settings.repoVisibility === 'private',
302
+ has_issues: false,
303
+ has_projects: false,
304
+ has_wiki: false,
305
+ auto_init: false,
306
+ };
307
+ const path = ownAccount ? '/user/repos' : `/orgs/${encodeURIComponent(location.owner)}/repos`;
308
+ try {
309
+ await api.gh.request('POST', path, { body, retry: false, signal });
310
+ }
311
+ catch (error) {
312
+ if (error instanceof GitHubApiError && (error.status === 401 || error.status === 403 || error.status === 404)) {
313
+ throw new Error(`The token cannot create repositories under ${location.owner} (${error.apiMessage}). The per-artifact strategy needs a token that may create repositories: a fine-grained token with "All repositories" plus Administration, Contents, and Pages write, or a classic token with the public_repo scope. Alternatively switch to the shared strategy.`);
314
+ }
315
+ throw error;
316
+ }
317
+ // Best effort: a topic makes every artifact repository easy to find on GitHub.
318
+ await api.gh.request('PUT', `/repos/${encodeURIComponent(location.owner)}/${encodeURIComponent(location.repo)}/topics`, {
319
+ body: { names: [ARTIFACT_TOPIC] }, allowStatus: [403, 404, 422], retry: false, signal,
320
+ }).catch(() => undefined);
321
+ }
322
+ /**
323
+ * Enforce the visibility policy at execution time too, so a missed or failed per-Agent
324
+ * restriction can never open a mutating tool to a subagent or a hidden preset.
325
+ */
326
+ assertVisible(toolName, exec) {
327
+ const agent = exec.agent;
328
+ if (agent === undefined)
329
+ return;
330
+ const preset = this.ctx.get('agentPresets')?.composedPreset(agent.ctx);
331
+ const delegated = (agent.session.header.delegationDepth ?? 0) > 0 || agent.session.header.origin === 'subagent';
332
+ const hidden = hiddenTools({
333
+ allTools: ALL_TOOLS, mutatingTools: MUTATING_TOOLS,
334
+ hideFromPresets: this.settings.hideFromPresets, subagentAccess: this.settings.subagentAccess,
335
+ }, preset, delegated);
336
+ if (hidden.includes(toolName)) {
337
+ throw new Error(delegated
338
+ ? `${toolName} is not available to delegated subagents; return the content to the parent agent so it can publish.`
339
+ : `${toolName} is not available in this mode.`);
340
+ }
341
+ }
342
+ async serialized(run) {
343
+ const next = this.writeQueue.then(run);
344
+ this.writeQueue = next.catch(() => undefined);
345
+ return await next;
346
+ }
347
+ /** Record a published or updated artifact in the registry. */
348
+ async track(entry) {
349
+ await this.registry.update(data => {
350
+ data.artifacts[entry.id] = entry;
351
+ });
352
+ }
353
+ /**
354
+ * Create a new artifact or update one in place.
355
+ * @param args - tool arguments.
356
+ * @param exec - tool execution.
357
+ */
358
+ async publish(args, exec) {
359
+ const signal = exec.signal;
360
+ this.assertVisible(PUBLISH_TOOL, exec);
361
+ const description = checkText('description', args.description, MAX_DESCRIPTION);
362
+ if (description === '')
363
+ throw new Error('description must not be empty');
364
+ if (args.path !== undefined && args.content !== undefined)
365
+ throw new Error('Pass either path or content, not both');
366
+ const updating = args.id !== undefined;
367
+ if (!updating && args.path === undefined && args.content === undefined)
368
+ throw new Error('A new artifact needs path or content');
369
+ if (!updating && (args.title === undefined || args.title.trim() === ''))
370
+ throw new Error('A new artifact needs a title');
371
+ if (!updating && (args.removeAssets !== undefined || args.baseRev !== undefined)) {
372
+ throw new Error('removeAssets and baseRev apply only when updating an existing artifact (pass its id)');
373
+ }
374
+ if (updating && args.slug !== undefined)
375
+ throw new Error('slug applies only to new artifacts; the id of an existing artifact never changes');
376
+ if (args.kind !== undefined && args.path === undefined && args.content === undefined)
377
+ throw new Error('kind needs new content (path or content) to go with it');
378
+ const title = args.title === undefined ? undefined : checkText('title', args.title, MAX_TITLE);
379
+ const sources = await this.readSources(args, exec);
380
+ const api = await this.api();
381
+ const data = await this.registry.read();
382
+ let id;
383
+ let location;
384
+ let newRepository = false;
385
+ if (updating) {
386
+ id = args.id;
387
+ location = (await this.locate(api, data, id, signal)).location;
388
+ }
389
+ else {
390
+ id = args.slug ?? idFromTitle(title, this.randomSuffix());
391
+ const known = data.artifacts[id];
392
+ if (known !== undefined) {
393
+ throw new Error(known.status === 'deleted'
394
+ ? `The id "${id}" belonged to a deleted artifact and is not reused; choose another slug`
395
+ : `An artifact with id "${id}" already exists; pass id to update it, or choose another slug`);
396
+ }
397
+ if (this.strategyOf(data).strategy === 'per-artifact') {
398
+ location = this.ownLocation(this.settings.owner ?? await this.tokenOwner(api, signal), id);
399
+ newRepository = true;
400
+ }
401
+ else {
402
+ location = this.sharedLocation((await this.sharedRepository(api, data, signal)).ref);
403
+ }
404
+ }
405
+ const problem = idProblem(id);
406
+ if (problem !== undefined)
407
+ throw new Error(problem);
408
+ if (newRepository && !REPO_NAME.test(location.repo))
409
+ throw new Error(`"${location.repo}" is not a valid repository name; choose a shorter slug or repoPrefix`);
410
+ if (this.settings.blockSecrets) {
411
+ const labels = [title ?? '', description, args.slug ?? '', ...(args.assets ?? []).map(asset => asset.name ?? ''), ...args.removeAssets ?? []];
412
+ const found = findSecrets(labels.join('\n'), [api.token]);
413
+ if (found.length > 0)
414
+ throw new Error(`Refusing to publish: the title, description, slug, or asset names appear to contain ${found.join(', ')}. Published artifacts are public.`);
415
+ }
416
+ // Check preconditions before asking, so the user is not asked about a doomed change.
417
+ const store = this.store(api, location, signal);
418
+ const repoName = repositoryName(location);
419
+ let existing;
420
+ let site;
421
+ let url;
422
+ if (newRepository) {
423
+ const taken = await api.gh.request('GET', `/repos/${encodeURIComponent(location.owner)}/${encodeURIComponent(location.repo)}`, { allowStatus: [404], signal });
424
+ if (taken.status !== 404)
425
+ throw new Error(`The repository ${repoName} already exists; choose another slug`);
426
+ url = this.settings.apiBaseUrl === GITHUB_API ? `${defaultSiteUrl(location.owner, location.repo)}/` : `the GitHub Pages site of ${repoName}`;
427
+ }
428
+ else {
429
+ site = await this.siteInfo(api, location, signal);
430
+ const before = await store.snapshot(signal);
431
+ existing = before.manifest.artifacts[id];
432
+ if (updating)
433
+ assertUpdatable(id, existing, args.baseRev);
434
+ else
435
+ assertCreatable(id, before.manifest);
436
+ if (!updating && (await store.artifactFiles(before, id)).length > 0) {
437
+ throw new Error(`The folder ${store.artifactDir(id)}/ already exists in ${repoName} but is not an artifact; choose another slug`);
438
+ }
439
+ if (before.state === 'ready' && await before.jekyll() === 'jekyll-site')
440
+ throw jekyllSiteError(location);
441
+ url = this.artifactUrl(site, location, id);
442
+ }
443
+ const shownTitle = title ?? existing?.title ?? id;
444
+ const where = newRepository
445
+ ? `Creates the new ${this.settings.repoVisibility} repository ${repoName} and turns on GitHub Pages for it.`
446
+ : `Repository: ${repoName}${site?.pages === 'disabled' ? ' (GitHub Pages gets turned on)' : ''}.`;
447
+ const facts = `${where} ${describePublish(sources, args, shownTitle)}`;
448
+ await requireApproval(this.ctx, this.settings.approval, exec, updating
449
+ ? {
450
+ toolName: PUBLISH_TOOL,
451
+ reason: `update artifact ${id} (rev ${existing.rev} -> ${existing.rev + 1}) at ${url}: ${facts}`,
452
+ display: `Update the public page ${url} (rev ${existing.rev} -> ${existing.rev + 1})? ${facts}`,
453
+ }
454
+ : {
455
+ toolName: PUBLISH_TOOL,
456
+ reason: `publish new artifact at ${url}: ${facts}`,
457
+ display: `Publish a new public page at ${url}? ${facts} Anyone with the link can view it.`,
458
+ });
459
+ if (newRepository)
460
+ await this.createRepository(api, location, shownTitle, url.startsWith('https://') ? url : undefined, signal);
461
+ const now = this.now().toISOString();
462
+ const committed = await this.serialized(() => store.mutate(async (snapshot) => {
463
+ const current = snapshot.manifest.artifacts[id];
464
+ if (updating)
465
+ assertUpdatable(id, current, args.baseRev);
466
+ else
467
+ assertCreatable(id, snapshot.manifest);
468
+ const dir = store.artifactDir(id);
469
+ const existingFiles = await store.artifactFiles(snapshot, id);
470
+ if (current === undefined && existingFiles.length > 0) {
471
+ throw new Error(`The folder ${dir}/ already exists in ${repoName} but is not an artifact; choose another slug`);
472
+ }
473
+ const kind = sources.kind ?? current?.kind ?? 'html';
474
+ const recordTitle = title ?? current.title;
475
+ const writes = [];
476
+ const deletes = [];
477
+ const finalFiles = new Set(existingFiles);
478
+ const pageOptions = { noindex: this.settings.noindex, csp: this.settings.csp };
479
+ if (sources.mainText !== undefined) {
480
+ if (kind === 'markdown') {
481
+ writes.push({ path: joinPath(dir, PAGE_FILE), text: renderMarkdownPage({ title: recordTitle, description, markdown: sources.mainText }, pageOptions) });
482
+ writes.push({ path: joinPath(dir, SOURCE_FILE), text: sources.mainText });
483
+ finalFiles.add(SOURCE_FILE);
484
+ }
485
+ else {
486
+ writes.push({ path: joinPath(dir, PAGE_FILE), text: prepareHtmlPage({ title: recordTitle, html: sources.mainText }, pageOptions) });
487
+ if (finalFiles.has(SOURCE_FILE)) {
488
+ deletes.push(joinPath(dir, SOURCE_FILE));
489
+ finalFiles.delete(SOURCE_FILE);
490
+ }
491
+ }
492
+ finalFiles.add(PAGE_FILE);
493
+ }
494
+ else if (current !== undefined && current.kind === 'markdown' && (title !== undefined && title !== current.title || description !== current.description)) {
495
+ // Title and description live in the rendered page; re-render from the stored source.
496
+ const source = await snapshot.readFile(joinPath(dir, SOURCE_FILE));
497
+ if (source !== undefined) {
498
+ writes.push({ path: joinPath(dir, PAGE_FILE), text: renderMarkdownPage({ title: recordTitle, description, markdown: decodeUtf8(source, SOURCE_FILE) }, pageOptions) });
499
+ }
500
+ }
501
+ for (const file of sources.files) {
502
+ writes.push(file.write(dir));
503
+ finalFiles.add(file.name);
504
+ }
505
+ for (const name of args.removeAssets ?? []) {
506
+ if (!finalFiles.has(name) || name === PAGE_FILE || name === SOURCE_FILE) {
507
+ throw new Error(`Cannot remove asset "${name}": it is not part of artifact ${id}`);
508
+ }
509
+ if (sources.files.some(file => file.name === name))
510
+ throw new Error(`Asset "${name}" is both added and removed`);
511
+ deletes.push(joinPath(dir, name));
512
+ finalFiles.delete(name);
513
+ }
514
+ const record = {
515
+ id, title: recordTitle, kind,
516
+ createdAt: current?.createdAt ?? now,
517
+ updatedAt: now,
518
+ rev: (current?.rev ?? 0) + 1,
519
+ files: [...finalFiles].sort(),
520
+ description,
521
+ };
522
+ return {
523
+ message: current === undefined ? `Publish artifact ${id}: ${oneLine(recordTitle)}` : `Update artifact ${id} to rev ${record.rev}: ${oneLine(recordTitle)}`,
524
+ writes, deletes,
525
+ manifest: { version: 1, artifacts: { ...snapshot.manifest.artifacts, [id]: record }, tombstones: snapshot.manifest.tombstones },
526
+ result: { record, created: current === undefined },
527
+ };
528
+ }, signal));
529
+ const { record, created } = committed.result;
530
+ const warnings = [];
531
+ // The page is committed now: everything below only adds warnings, and the artifact is always tracked.
532
+ try {
533
+ if (newRepository || site?.pages === 'disabled') {
534
+ warnings.push(...await this.ensurePages(api, location, signal, newRepository));
535
+ site = await this.siteInfo(api, location, signal, true);
536
+ url = this.artifactUrl(site, location, id);
537
+ }
538
+ warnings.push(...site?.warnings.filter(warning => !warning.startsWith('GitHub Pages is not enabled')) ?? []);
539
+ }
540
+ catch (error) {
541
+ if (signal.aborted)
542
+ throw error;
543
+ warnings.push(`Published, but could not finish checking the Pages site: ${errorText(error)}`);
544
+ }
545
+ await this.track({
546
+ id, title: record.title, kind: record.kind, description, url, location,
547
+ createdAt: data.artifacts[id]?.createdAt ?? record.createdAt, updatedAt: record.updatedAt, rev: record.rev, status: 'published',
548
+ });
549
+ return {
550
+ id, title: record.title, kind: record.kind, url, repository: repoName, newRepository, rev: record.rev, created,
551
+ commit: committed.commitSha, files: record.files, warnings,
552
+ };
553
+ }
554
+ async readSources(args, exec) {
555
+ const limit = this.settings.maxPublishBytes;
556
+ const guard = this.settings.blockSecrets;
557
+ let used = 0;
558
+ const charge = (bytes, label) => {
559
+ used += bytes;
560
+ if (used > limit)
561
+ throw new Error(`${label} brings this publish to ${formatBytes(used)}, above the ${formatBytes(limit)} limit`);
562
+ };
563
+ const refuseCredentialPath = (path) => {
564
+ const problem = guard ? credentialPathProblem(path) : undefined;
565
+ if (problem !== undefined)
566
+ throw new Error(`Refusing to publish ${problem}. Published artifacts are public.`);
567
+ };
568
+ const files = [];
569
+ const screened = [];
570
+ let mainText;
571
+ let mainBytes = 0;
572
+ let kind = args.kind;
573
+ if (args.path !== undefined) {
574
+ refuseCredentialPath(args.path);
575
+ const fromName = kindFromName(args.path);
576
+ if (fromName === 'unsupported')
577
+ throw new Error(`${args.path} cannot be published as a page; use a .html, .htm, .md, .markdown, or .txt file`);
578
+ kind ??= fromName;
579
+ if (kind === undefined)
580
+ throw new Error(`${args.path} is a .txt file; pass kind (markdown or html)`);
581
+ const bytes = await readWorkspaceFile(this.ctx, exec, args.path, limit);
582
+ charge(bytes.length, args.path);
583
+ mainText = decodeUtf8(bytes, args.path);
584
+ mainBytes = bytes.length;
585
+ }
586
+ else if (args.content !== undefined) {
587
+ if (args.content.trim() === '')
588
+ throw new Error('content must not be empty');
589
+ mainText = args.content;
590
+ mainBytes = Buffer.byteLength(mainText);
591
+ charge(mainBytes, 'content');
592
+ kind ??= isHtmlDocument(mainText) ? 'html' : 'markdown';
593
+ }
594
+ if (mainText !== undefined)
595
+ screened.push({ label: args.path ?? 'content', text: mainText });
596
+ const names = new Set();
597
+ for (const asset of args.assets ?? []) {
598
+ refuseCredentialPath(asset.path);
599
+ const name = asset.name ?? baseName(asset.path);
600
+ const problem = assetNameProblem(name);
601
+ if (problem !== undefined)
602
+ throw new Error(problem);
603
+ if (guard && credentialPathProblem(name) !== undefined)
604
+ throw new Error(`Refusing to publish an asset named "${name}": it looks like a credential file`);
605
+ if (names.has(name))
606
+ throw new Error(`Asset name "${name}" is used twice`);
607
+ names.add(name);
608
+ const bytes = await readWorkspaceFile(this.ctx, exec, asset.path, limit);
609
+ charge(bytes.length, asset.path);
610
+ if (isTextName(name)) {
611
+ const text = decodeUtf8(bytes, asset.path);
612
+ screened.push({ label: asset.path, text });
613
+ files.push({ source: asset.path, name, bytes: bytes.length, write: dir => ({ path: joinPath(dir, name), text }) });
614
+ }
615
+ else {
616
+ // Binary files are screened too: PDFs, data files, and renamed text often carry plain text.
617
+ screened.push({ label: asset.path, text: Buffer.from(bytes).toString('latin1') });
618
+ files.push({ source: asset.path, name, bytes: bytes.length, write: dir => ({ path: joinPath(dir, name), bytes }) });
619
+ }
620
+ }
621
+ if (guard) {
622
+ const token = await this.resolveToken();
623
+ for (const { label, text } of screened) {
624
+ const found = findSecrets(text, token === undefined ? [] : [token.value]);
625
+ if (found.length > 0) {
626
+ throw new Error(`Refusing to publish ${label}: it appears to contain ${found.join(', ')}. Published artifacts are public; remove the secret and retry.`);
627
+ }
628
+ }
629
+ }
630
+ return { kind, mainText, mainBytes, files, totalBytes: used };
631
+ }
632
+ /**
633
+ * List tracked artifacts, newest first. Artifacts found in the shared repository but missing
634
+ * from the local registry (published from another machine) are added to it.
635
+ * @param args - filter and paging.
636
+ * @param signal - cancellation.
637
+ */
638
+ async list(args, signal) {
639
+ const offset = args.offset ?? 0;
640
+ const limit = args.limit ?? 50;
641
+ if (!Number.isSafeInteger(offset) || offset < 0)
642
+ throw new Error('offset must be a non-negative integer');
643
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200)
644
+ throw new Error('limit must be an integer from 1 to 200');
645
+ const notes = [];
646
+ let data = await this.registry.read();
647
+ try {
648
+ const api = await this.api();
649
+ const shared = this.sharedLocation((await this.sharedRepository(api, data, signal)).ref);
650
+ const snapshot = await this.store(api, shared, signal).snapshot(signal);
651
+ const missing = Object.values(snapshot.manifest.artifacts).filter(record => data.artifacts[record.id] === undefined);
652
+ if (missing.length > 0) {
653
+ const site = await this.siteInfo(api, shared, signal);
654
+ data = await this.registry.update(next => {
655
+ for (const record of missing) {
656
+ next.artifacts[record.id] ??= {
657
+ id: record.id, title: record.title, kind: record.kind, url: this.artifactUrl(site, shared, record.id), location: shared,
658
+ createdAt: record.createdAt, updatedAt: record.updatedAt, rev: record.rev, status: 'published',
659
+ ...record.description === undefined ? {} : { description: record.description },
660
+ };
661
+ }
662
+ });
663
+ }
664
+ }
665
+ catch (error) {
666
+ notes.push(`Could not check the shared repository for artifacts published elsewhere: ${errorText(error)}`);
667
+ }
668
+ try {
669
+ data = await this.discoverOwnRepositories(data, signal);
670
+ }
671
+ catch (error) {
672
+ notes.push(`Could not look for per-artifact repositories on GitHub: ${errorText(error)}`);
673
+ }
674
+ if (!await exists(this.registry.indexFile))
675
+ data = await this.registry.update(() => undefined);
676
+ const query = args.query?.trim().toLowerCase() ?? '';
677
+ const entries = Object.values(data.artifacts)
678
+ .filter(entry => args.includeDeleted === true || entry.status === 'published')
679
+ .filter(entry => query === '' || entry.id.includes(query) || entry.title.toLowerCase().includes(query)
680
+ || (entry.description?.toLowerCase().includes(query) ?? false) || repositoryName(entry.location).toLowerCase().includes(query))
681
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id));
682
+ const page = entries.slice(offset, offset + limit).map((entry) => ({
683
+ id: entry.id, title: entry.title, kind: entry.kind, url: entry.url, repository: repositoryName(entry.location),
684
+ rev: entry.rev, updatedAt: entry.updatedAt, status: entry.status,
685
+ ...entry.description === undefined ? {} : { description: entry.description },
686
+ }));
687
+ return {
688
+ total: entries.length, artifacts: page, indexFile: this.registry.indexFile, notes,
689
+ ...offset + page.length < entries.length ? { nextOffset: offset + page.length } : {},
690
+ };
691
+ }
692
+ /**
693
+ * Find repositories created per artifact (tagged with the artifact topic) that the registry does
694
+ * not know yet, for example ones published from another machine, and track their artifacts.
695
+ */
696
+ async discoverOwnRepositories(data, signal) {
697
+ const api = await this.api();
698
+ const owner = (this.settings.owner ?? await this.tokenOwner(api, signal)).toLowerCase();
699
+ const known = new Set(Object.values(data.artifacts).map(entry => repositoryName(entry.location).toLowerCase()));
700
+ const found = [];
701
+ for (let page = 1; page <= 5; page++) {
702
+ const repos = await api.gh.request('GET', `/user/repos?per_page=100&page=${page}&sort=updated`, { signal });
703
+ for (const repo of repos.data) {
704
+ if (repo.owner.login.toLowerCase() !== owner || !(repo.topics ?? []).includes(ARTIFACT_TOPIC))
705
+ continue;
706
+ if (known.has(`${repo.owner.login}/${repo.name}`.toLowerCase()))
707
+ continue;
708
+ const location = {
709
+ owner: repo.owner.login, repo: repo.name, branch: this.settings.branch, siteDir: this.settings.siteDir, pathPrefix: '', layout: 'root',
710
+ };
711
+ const snapshot = await this.store(api, location, signal).snapshot(signal).catch(() => undefined);
712
+ if (snapshot === undefined)
713
+ continue;
714
+ for (const record of Object.values(snapshot.manifest.artifacts)) {
715
+ if (data.artifacts[record.id] !== undefined)
716
+ continue;
717
+ const site = await this.siteInfo(api, location, signal);
718
+ found.push({
719
+ id: record.id, title: record.title, kind: record.kind, url: this.artifactUrl(site, location, record.id), location,
720
+ createdAt: record.createdAt, updatedAt: record.updatedAt, rev: record.rev, status: 'published',
721
+ ...record.description === undefined ? {} : { description: record.description },
722
+ });
723
+ }
724
+ }
725
+ if (repos.data.length < 100)
726
+ break;
727
+ }
728
+ if (found.length === 0)
729
+ return data;
730
+ return await this.registry.update(next => {
731
+ for (const entry of found)
732
+ next.artifacts[entry.id] ??= entry;
733
+ });
734
+ }
735
+ /**
736
+ * Read an artifact's source or one of its text files.
737
+ * @param args - id, file, window.
738
+ * @param signal - cancellation.
739
+ */
740
+ async read(args, signal) {
741
+ const offset = args.offset ?? 0;
742
+ const limit = args.limit ?? READ_DEFAULT_CHARS;
743
+ if (!Number.isSafeInteger(offset) || offset < 0)
744
+ throw new Error('offset must be a non-negative integer');
745
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > READ_MAX_CHARS)
746
+ throw new Error(`limit must be an integer from 1 to ${READ_MAX_CHARS}`);
747
+ const api = await this.api();
748
+ const { location } = await this.locate(api, await this.registry.read(), args.id, signal);
749
+ const site = await this.siteInfo(api, location, signal);
750
+ const store = this.store(api, location, signal);
751
+ const snapshot = await store.snapshot(signal);
752
+ const record = requireRecord(snapshot.manifest, args.id);
753
+ const file = args.file ?? (record.kind === 'markdown' ? SOURCE_FILE : PAGE_FILE);
754
+ if (!record.files.includes(file))
755
+ throw new Error(`Artifact ${args.id} has no file "${file}"; its files are ${record.files.join(', ')}`);
756
+ if (file !== PAGE_FILE && file !== SOURCE_FILE && !isTextName(file))
757
+ throw new Error(`"${file}" is a binary file and cannot be read as text`);
758
+ const bytes = await snapshot.readFile(joinPath(store.artifactDir(args.id), file));
759
+ if (bytes === undefined)
760
+ throw new Error(`"${file}" is listed for artifact ${args.id} but missing on the branch`);
761
+ const decoded = decodeUtf8(bytes, file);
762
+ // The served page carries tags the plugin injected; give back the page as it was written.
763
+ const text = file === PAGE_FILE && record.kind === 'html' ? stripInjected(decoded) : decoded;
764
+ const content = text.slice(offset, offset + limit);
765
+ return {
766
+ id: record.id, title: record.title, kind: record.kind, url: this.artifactUrl(site, location, record.id),
767
+ repository: repositoryName(location), rev: record.rev,
768
+ file, files: record.files, totalChars: text.length, offset, content,
769
+ ...offset + content.length < text.length ? { nextOffset: offset + content.length } : {},
770
+ };
771
+ }
772
+ /**
773
+ * Delete an artifact for the agent; its id is never reused. Asks for approval per configuration.
774
+ * @param args - id.
775
+ * @param exec - tool execution.
776
+ */
777
+ async remove(args, exec) {
778
+ this.assertVisible(DELETE_TOOL, exec);
779
+ return await this.deleteArtifact(args.id, exec.signal, async ({ url, repoName, record }) => {
780
+ await requireApproval(this.ctx, this.settings.approval, exec, {
781
+ toolName: DELETE_TOOL,
782
+ reason: `delete artifact ${args.id} at ${url} from ${repoName} (title ${quote(record.title)})`,
783
+ display: `Delete the public page ${url} (title ${quote(record.title)}, ${record.files.length} file${record.files.length === 1 ? '' : 's'}, repository ${repoName})? The link stops working; copies others saved and the repository history remain.`,
784
+ });
785
+ });
786
+ }
787
+ /**
788
+ * Delete an artifact the user chose in the Artifacts panel. The click (after the panel's own
789
+ * confirmation) is the user's decision, so the agent approval prompt does not apply.
790
+ * @param id - artifact id.
791
+ * @param signal - cancellation.
792
+ */
793
+ async removeByUser(id, signal) {
794
+ const problem = idProblem(id);
795
+ if (problem !== undefined)
796
+ throw new Error(problem);
797
+ return await this.deleteArtifact(id, signal, async () => undefined);
798
+ }
799
+ async deleteArtifact(id, signal, approve) {
800
+ const api = await this.api();
801
+ const { location } = await this.locate(api, await this.registry.read(), id, signal);
802
+ const site = await this.siteInfo(api, location, signal);
803
+ const store = this.store(api, location, signal);
804
+ const before = await store.snapshot(signal);
805
+ const record = requireRecord(before.manifest, id);
806
+ const url = this.artifactUrl(site, location, id);
807
+ const repoName = repositoryName(location);
808
+ await approve({ url, repoName, record });
809
+ const committed = await this.serialized(() => store.mutate(async (snapshot) => {
810
+ const current = requireRecord(snapshot.manifest, id);
811
+ const dir = store.artifactDir(id);
812
+ const deletes = (await store.artifactFiles(snapshot, id)).map(file => joinPath(dir, file));
813
+ const artifacts = { ...snapshot.manifest.artifacts };
814
+ delete artifacts[id];
815
+ return {
816
+ message: `Delete artifact ${id}: ${oneLine(current.title)}`,
817
+ writes: [], deletes,
818
+ manifest: { version: 1, artifacts, tombstones: [...snapshot.manifest.tombstones, id] },
819
+ result: null,
820
+ };
821
+ }, signal));
822
+ const deletedAt = this.now().toISOString();
823
+ await this.registry.update(data => {
824
+ const known = data.artifacts[id];
825
+ data.artifacts[id] = {
826
+ ...known ?? {
827
+ id, title: record.title, kind: record.kind, url, location,
828
+ createdAt: record.createdAt, rev: record.rev,
829
+ ...record.description === undefined ? {} : { description: record.description },
830
+ },
831
+ updatedAt: deletedAt, status: 'deleted', deletedAt,
832
+ };
833
+ });
834
+ const notes = location.layout === 'root'
835
+ ? [`The repository ${repoName} still exists (now without the page); delete it on GitHub if it is no longer needed.`]
836
+ : [];
837
+ return { id, url, repository: repoName, deleted: true, commit: committed.commitSha, notes };
838
+ }
839
+ /**
840
+ * Show or change where new artifacts go.
841
+ * @param args - action and its inputs.
842
+ * @param exec - tool execution.
843
+ */
844
+ async repository(args, exec) {
845
+ const signal = exec.signal;
846
+ if (args.action !== 'show')
847
+ this.assertVisible(PUBLISH_TOOL, exec);
848
+ const warnings = [];
849
+ switch (args.action) {
850
+ case 'show':
851
+ break;
852
+ case 'link': {
853
+ if (args.repository === undefined || args.repository.trim() === '')
854
+ throw new Error('link needs repository (owner/name or a remote URL)');
855
+ const ref = parseRepository(args.repository, this.settings.apiBaseUrl);
856
+ const api = await this.api();
857
+ const repo = await api.gh.request('GET', `/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}`, { allowStatus: [404], signal });
858
+ if (repo.status === 404)
859
+ throw new Error(`Repository ${ref.owner}/${ref.repo} was not found, or the token cannot access it`);
860
+ if (repo.data.permissions?.push === false)
861
+ throw new Error(`The token's account cannot push to ${ref.owner}/${ref.repo}`);
862
+ if (repo.data.archived === true)
863
+ throw new Error(`${ref.owner}/${ref.repo} is archived and cannot receive new pages`);
864
+ if (ref.repo.toLowerCase() === `${ref.owner.toLowerCase()}.github.io`) {
865
+ warnings.push(`${ref.owner}/${ref.repo} is a user or organization site: artifacts will share its root and origin. A dedicated repository is safer.`);
866
+ }
867
+ await this.saveChoice([{ op: 'set', path: ['repository'], value: `${ref.owner}/${ref.repo}` }, { op: 'set', path: ['repoStrategy'], value: 'shared' }], data => {
868
+ data.settings.link = { owner: ref.owner, repo: ref.repo, linkedAt: this.now().toISOString() };
869
+ data.settings.strategy = 'shared';
870
+ }, warnings);
871
+ break;
872
+ }
873
+ case 'unlink':
874
+ await this.saveChoice([{ op: 'unset', path: ['repository'] }], data => {
875
+ delete data.settings.link;
876
+ }, warnings);
877
+ break;
878
+ case 'set_strategy':
879
+ if (args.strategy === undefined)
880
+ throw new Error('set_strategy needs strategy (shared or per-artifact)');
881
+ await this.saveChoice([{ op: 'set', path: ['repoStrategy'], value: args.strategy }], data => {
882
+ data.settings.strategy = args.strategy;
883
+ }, warnings);
884
+ break;
885
+ }
886
+ return await this.describeRepository(signal, warnings);
887
+ }
888
+ /**
889
+ * Persist a choice made in chat. With the dsh settings service the plugin's own configuration is
890
+ * updated, so the Plugins page and the profile file show it; without it (or when a higher layer
891
+ * fixes the value) the choice is kept in the registry, which then overrides the configuration.
892
+ */
893
+ async saveChoice(ops, fallback, warnings) {
894
+ const settings = this.ctx.get('settings');
895
+ if (settings !== undefined && typeof settings.mutate === 'function' && this.namespace !== undefined) {
896
+ try {
897
+ await settings.mutate(this.namespace, ops);
898
+ // The configuration is authoritative now; drop overrides an earlier fallback stored.
899
+ await this.registry.update(data => {
900
+ delete data.settings.link;
901
+ delete data.settings.strategy;
902
+ });
903
+ return;
904
+ }
905
+ catch (error) {
906
+ warnings.push(`Could not save this in the plugin settings (${errorText(error)}); it is kept in the plugin's registry instead and overrides the settings.`);
907
+ }
908
+ }
909
+ await this.registry.update(fallback);
910
+ }
911
+ async describeRepository(signal, warnings) {
912
+ const data = await this.registry.read();
913
+ const { strategy, source } = this.strategyOf(data);
914
+ let api;
915
+ try {
916
+ api = await this.api();
917
+ }
918
+ catch (error) {
919
+ warnings.push(errorText(error));
920
+ }
921
+ let shared;
922
+ try {
923
+ shared = await this.sharedRepository(api, data, signal);
924
+ }
925
+ catch (error) {
926
+ warnings.push(errorText(error));
927
+ shared = { ref: { owner: this.settings.owner ?? '(token user)', repo: this.settings.repo }, source: 'token user' };
928
+ }
929
+ const newOwner = this.settings.owner ?? (api === undefined ? '(token user)' : await this.tokenOwner(api, signal).catch(() => '(token user)'));
930
+ const result = {
931
+ strategy, strategySource: source,
932
+ repository: repositoryName(shared.ref), repositorySource: shared.source,
933
+ branch: this.settings.branch,
934
+ newRepositories: `${newOwner}/${this.settings.repoPrefix}<id> (${this.settings.repoVisibility})`,
935
+ registryFile: this.registry.file, indexFile: this.registry.indexFile, warnings,
936
+ };
937
+ if (api !== undefined && strategy === 'shared' && !shared.ref.owner.startsWith('(')) {
938
+ try {
939
+ const site = await this.siteInfo(api, this.sharedLocation(shared.ref), signal, true);
940
+ result.siteUrl = `${site.url}/`;
941
+ result.pages = site.pages;
942
+ warnings.push(...site.warnings);
943
+ }
944
+ catch (error) {
945
+ warnings.push(errorText(error));
946
+ }
947
+ }
948
+ if (strategy === 'per-artifact') {
949
+ warnings.push('Each new artifact gets its own repository, which needs a token that may create repositories (fine-grained: all repositories with Administration, Contents, and Pages write; classic: public_repo). Existing artifacts stay where they are.');
950
+ }
951
+ return result;
952
+ }
953
+ /**
954
+ * Diagnose the setup and the deployment of the latest change.
955
+ * @param args - optional artifact id and whether to wait for deployment.
956
+ * @param signal - cancellation.
957
+ */
958
+ async status(args, signal) {
959
+ // Setup problems make the result not ready; notes describe a deployment that is still under way.
960
+ const problems = [];
961
+ const notes = [];
962
+ const settings = this.settings;
963
+ const data = await this.registry.read();
964
+ const { strategy } = this.strategyOf(data);
965
+ const token = await this.resolveToken();
966
+ const result = {
967
+ ready: false, strategy, repository: `${settings.owner ?? '(token user)'}/${settings.repo}`, branch: settings.branch,
968
+ token: { configured: token !== undefined, ...token === undefined ? {} : { source: token.source } },
969
+ pages: 'unknown', registryFile: this.registry.file, problems,
970
+ };
971
+ if (token === undefined) {
972
+ problems.push(this.missingTokenError().message);
973
+ return result;
974
+ }
975
+ let api;
976
+ let location;
977
+ try {
978
+ api = await this.api();
979
+ location = args.id !== undefined
980
+ ? (await this.locate(api, data, args.id, signal)).location
981
+ : this.sharedLocation((await this.sharedRepository(api, data, signal)).ref);
982
+ }
983
+ catch (error) {
984
+ problems.push(errorText(error));
985
+ return result;
986
+ }
987
+ const { gh } = api;
988
+ const repoPath = `/repos/${encodeURIComponent(location.owner)}/${encodeURIComponent(location.repo)}`;
989
+ result.repository = repositoryName(location);
990
+ result.branch = location.branch;
991
+ if (strategy === 'per-artifact' && args.id === undefined) {
992
+ notes.push(`New artifacts get their own repository (${settings.owner ?? 'token user'}/${settings.repoPrefix}<id>); the checks below are for the shared repository ${result.repository}.`);
993
+ }
994
+ const repo = await gh.request('GET', repoPath, { allowStatus: [404], signal });
995
+ if (repo.status === 404) {
996
+ const message = `Repository ${result.repository} was not found or the token cannot access it. Ask the user to create or link a repository and grant the token access (see the dsh-gh-pages-artifacts README); do not run setup commands yourself.`;
997
+ if (strategy === 'per-artifact' && args.id === undefined)
998
+ notes.push(message);
999
+ else
1000
+ problems.push(message);
1001
+ result.ready = problems.length === 0;
1002
+ problems.push(...notes);
1003
+ return result;
1004
+ }
1005
+ if (repo.data.permissions?.push === false)
1006
+ problems.push(`The token's account cannot push to ${result.repository}; it needs write access to the repository.`);
1007
+ let site;
1008
+ try {
1009
+ site = await this.siteInfo(api, location, signal, true);
1010
+ }
1011
+ catch (error) {
1012
+ problems.push(errorText(error));
1013
+ return result;
1014
+ }
1015
+ result.siteUrl = `${site.url}/`;
1016
+ result.pages = site.pages;
1017
+ problems.push(...site.warnings);
1018
+ if (site.pages === 'unknown')
1019
+ problems.push(`Cannot check the GitHub Pages settings because the token lacks Pages: read. Make sure Pages deploys ${location.branch} ${location.siteDir === '' ? '/' : `/${location.siteDir}`}, or grant Pages: read-only so this check can run.`);
1020
+ let snapshot;
1021
+ try {
1022
+ snapshot = await this.store(api, location, signal).snapshot(signal);
1023
+ if (snapshot.state === 'missing-branch')
1024
+ notes.push(`Branch ${location.branch} does not exist yet; the first publish creates it.`);
1025
+ }
1026
+ catch (error) {
1027
+ problems.push(errorText(error));
1028
+ }
1029
+ const latestBuild = async () => {
1030
+ const build = await gh.request('GET', `${repoPath}/pages/builds/latest`, { allowStatus: [403, 404], signal });
1031
+ if (build.status !== 200 || build.data.status === undefined)
1032
+ return undefined;
1033
+ const message = build.data.error?.message;
1034
+ return {
1035
+ status: build.data.status,
1036
+ // GitHub reports a null commit for builds it has not attributed yet.
1037
+ ...typeof build.data.commit === 'string' ? { commit: build.data.commit } : {},
1038
+ ...typeof message === 'string' && message !== '' ? { error: message } : {},
1039
+ };
1040
+ };
1041
+ const headSha = snapshot?.headSha;
1042
+ if (headSha !== undefined)
1043
+ result.headCommit = headSha;
1044
+ // Real Pages deployments take one to several minutes; wait up to five.
1045
+ const deadline = this.now().getTime() + DEPLOY_WAIT_MS;
1046
+ const sleep = this.hooks.sleep ?? ((ms, s) => new Promise((resolve, reject) => {
1047
+ const timer = setTimeout(resolve, ms);
1048
+ s?.addEventListener('abort', () => { clearTimeout(timer); reject(s.reason); }, { once: true });
1049
+ }));
1050
+ const settled = (current) => current === undefined || current.status === 'errored'
1051
+ || (current.status === 'built' && (headSha === undefined || current.commit === headSha));
1052
+ let build = site.pages === 'disabled' ? undefined : await latestBuild();
1053
+ while (args.wait === true && !settled(build) && this.now().getTime() < deadline) {
1054
+ await sleep(5000, signal);
1055
+ build = await latestBuild();
1056
+ }
1057
+ if (build !== undefined) {
1058
+ result.latestBuild = build;
1059
+ if (headSha !== undefined) {
1060
+ result.deployed = build.status === 'built' && build.commit === headSha;
1061
+ if (build.status === 'errored')
1062
+ problems.push(`The latest Pages build failed: ${build.error ?? 'no details'}`);
1063
+ else if (!result.deployed)
1064
+ notes.push(`The latest change (commit ${headSha.slice(0, 7)}) is not deployed yet (Pages build: ${build.status}${build.commit === undefined || build.commit === headSha ? '' : ` for ${build.commit.slice(0, 7)}`}); deployments usually take one to three minutes.`);
1065
+ }
1066
+ else if (build.status === 'errored') {
1067
+ problems.push(`The latest Pages build failed: ${build.error ?? 'no details'}`);
1068
+ }
1069
+ }
1070
+ else if (site.pages !== 'disabled' && (args.wait === true || args.id !== undefined)) {
1071
+ notes.push('Cannot see Pages builds (grant the token Pages: read-only), so "live" only means the URL responds; an update may still show the previous version for a few minutes.');
1072
+ }
1073
+ if (args.id !== undefined && snapshot !== undefined) {
1074
+ const record = snapshot.manifest.artifacts[args.id];
1075
+ if (record === undefined) {
1076
+ notes.push(`No artifact with id ${args.id}`);
1077
+ }
1078
+ else {
1079
+ const url = this.artifactUrl(site, location, args.id);
1080
+ let httpStatus = await this.probe(url, signal);
1081
+ while (args.wait === true && httpStatus !== 200 && this.now().getTime() < deadline) {
1082
+ await sleep(5000, signal);
1083
+ httpStatus = await this.probe(url, signal);
1084
+ }
1085
+ result.artifact = { id: args.id, url, live: httpStatus === 200, ...httpStatus === undefined ? {} : { httpStatus } };
1086
+ if (httpStatus !== 200)
1087
+ notes.push(`${url} does not respond with 200 yet${httpStatus === undefined ? '' : ` (HTTP ${httpStatus})`}; deployments usually take one to three minutes.`);
1088
+ }
1089
+ }
1090
+ result.ready = problems.length === 0;
1091
+ problems.push(...notes);
1092
+ return result;
1093
+ }
1094
+ async probe(url, signal) {
1095
+ try {
1096
+ const fetchImpl = this.hooks.fetch ?? ((input, init) => globalThis.fetch(input, init));
1097
+ const response = await fetchImpl(url, {
1098
+ method: 'HEAD', redirect: 'follow', signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]),
1099
+ headers: { 'User-Agent': `dsh-gh-pages-artifacts/${VERSION}` },
1100
+ });
1101
+ return response.status;
1102
+ }
1103
+ catch (error) {
1104
+ if (signal.aborted)
1105
+ throw error;
1106
+ return undefined;
1107
+ }
1108
+ }
1109
+ }
1110
+ function assertCreatable(id, manifest) {
1111
+ if (manifest.artifacts[id] !== undefined)
1112
+ throw new Error(`An artifact with id "${id}" already exists; pass id to update it, or choose another slug`);
1113
+ if (manifest.tombstones.includes(id))
1114
+ throw new Error(`The id "${id}" belonged to a deleted artifact and is not reused; choose another slug`);
1115
+ }
1116
+ function assertUpdatable(id, record, baseRev) {
1117
+ if (record === undefined)
1118
+ throw new Error(`No artifact with id "${id}"; use artifact_list to find ids, or omit id to create a new artifact`);
1119
+ if (baseRev !== undefined && baseRev !== record.rev) {
1120
+ throw new Error(`Artifact ${id} changed since rev ${baseRev}; it is now rev ${record.rev}. Read it again with artifact_read and merge your changes.`);
1121
+ }
1122
+ }
1123
+ function requireRecord(manifest, id) {
1124
+ const record = manifest.artifacts[id];
1125
+ if (record === undefined) {
1126
+ throw new Error(manifest.tombstones.includes(id) ? `Artifact ${id} was deleted` : `No artifact with id "${id}"; use artifact_list to find ids`);
1127
+ }
1128
+ return record;
1129
+ }
1130
+ /** Kind implied by a page file name; undefined for .txt (kind must be given); 'unsupported' otherwise. */
1131
+ function kindFromName(name) {
1132
+ const lower = name.toLowerCase();
1133
+ if (lower.endsWith('.html') || lower.endsWith('.htm'))
1134
+ return 'html';
1135
+ if (lower.endsWith('.md') || lower.endsWith('.markdown') || lower.endsWith('.mdown'))
1136
+ return 'markdown';
1137
+ if (lower.endsWith('.txt'))
1138
+ return undefined;
1139
+ return 'unsupported';
1140
+ }
1141
+ /** Inline content is HTML only when it is clearly a whole document; everything else is Markdown. */
1142
+ function isHtmlDocument(text) {
1143
+ return /^?\s*(?:<!doctype html|<html[\s>])/i.test(text);
1144
+ }
1145
+ /**
1146
+ * Trusted facts for the approval prompt, built from validated arguments rather than free text, so
1147
+ * a crafted title cannot hide what is being published.
1148
+ */
1149
+ function describePublish(sources, args, title) {
1150
+ const parts = [`Title: ${quote(title)}.`];
1151
+ if (sources.mainText !== undefined) {
1152
+ const origin = args.path !== undefined ? `workspace file ${quote(args.path)}` : `inline content (${sources.mainText.length} characters)`;
1153
+ parts.push(`Page: ${origin} as ${sources.kind}, ${formatBytes(sources.mainBytes)}.`);
1154
+ }
1155
+ else {
1156
+ parts.push('Page content unchanged.');
1157
+ }
1158
+ if (sources.files.length > 0) {
1159
+ const shown = sources.files.slice(0, 8).map(file => `${quote(file.source)} as ${quote(file.name)}`);
1160
+ const more = sources.files.length > 8 ? ` and ${sources.files.length - 8} more` : '';
1161
+ parts.push(`Assets: ${shown.join(', ')}${more}.`);
1162
+ }
1163
+ if (args.removeAssets !== undefined && args.removeAssets.length > 0)
1164
+ parts.push(`Removes: ${args.removeAssets.map(quote).join(', ')}.`);
1165
+ parts.push(`Total ${formatBytes(sources.totalBytes)}.`);
1166
+ return parts.join(' ');
1167
+ }
1168
+ /**
1169
+ * Validate a free-text field shown to people: single line, no control or bidi characters, bounded.
1170
+ * @returns the trimmed text.
1171
+ */
1172
+ function checkText(field, value, max) {
1173
+ const text = value.trim();
1174
+ if (/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(text))
1175
+ throw new Error(`${field} must be a single line without control or formatting characters`);
1176
+ if (text.length > max)
1177
+ throw new Error(`${field} must be at most ${max} characters`);
1178
+ return text;
1179
+ }
1180
+ function quote(text) {
1181
+ return JSON.stringify(text.length > 120 ? `${text.slice(0, 117)}...` : text);
1182
+ }
1183
+ function oneLine(text, max = 72) {
1184
+ const line = text.replace(/\s+/g, ' ').trim();
1185
+ return line.length > max ? `${line.slice(0, max - 3)}...` : line;
1186
+ }
1187
+ function errorText(error) {
1188
+ return error instanceof Error ? error.message : String(error);
1189
+ }
1190
+ async function exists(path) {
1191
+ try {
1192
+ await access(path);
1193
+ return true;
1194
+ }
1195
+ catch {
1196
+ return false;
1197
+ }
1198
+ }
1199
+ /**
1200
+ * The default public URL of a project site, or of a user/organization site repository.
1201
+ * @param owner - repository owner.
1202
+ * @param repo - repository name.
1203
+ */
1204
+ export function defaultSiteUrl(owner, repo) {
1205
+ const host = `${owner.toLowerCase()}.github.io`;
1206
+ return repo.toLowerCase() === host ? `https://${host}` : `https://${host}/${repo}`;
1207
+ }
1208
+ function normalizeSiteUrl(url) {
1209
+ const parsed = new URL(url);
1210
+ if (parsed.protocol === 'http:' && parsed.hostname.endsWith('.github.io'))
1211
+ parsed.protocol = 'https:';
1212
+ return parsed.href.replace(/\/+$/, '');
1213
+ }
1214
+ function joinUrl(base, path) {
1215
+ return path === '' ? base : `${base}/${path.split('/').map(encodeURIComponent).join('/')}`;
1216
+ }