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/LICENSE +21 -0
- package/README.md +227 -0
- package/assets/artifact-pages/SKILL.md +84 -0
- package/bin/setup.mjs +270 -0
- package/client/client.js +564 -0
- package/cordis.patch.yml +15 -0
- package/icon.svg +6 -0
- package/lib/approval.d.ts +26 -0
- package/lib/approval.js +59 -0
- package/lib/config.d.ts +204 -0
- package/lib/config.js +241 -0
- package/lib/content.d.ts +43 -0
- package/lib/content.js +142 -0
- package/lib/github.d.ts +78 -0
- package/lib/github.js +190 -0
- package/lib/index.d.ts +22 -0
- package/lib/index.js +48 -0
- package/lib/manifest.d.ts +88 -0
- package/lib/manifest.js +172 -0
- package/lib/names.d.ts +17 -0
- package/lib/names.js +17 -0
- package/lib/prompt.d.ts +15 -0
- package/lib/prompt.js +33 -0
- package/lib/registry.d.ts +70 -0
- package/lib/registry.js +151 -0
- package/lib/render.d.ts +43 -0
- package/lib/render.js +128 -0
- package/lib/service.d.ts +292 -0
- package/lib/service.js +1216 -0
- package/lib/skill.d.ts +16 -0
- package/lib/skill.js +59 -0
- package/lib/store.d.ts +167 -0
- package/lib/store.js +379 -0
- package/lib/tools.d.ts +10 -0
- package/lib/tools.js +320 -0
- package/lib/visibility.d.ts +34 -0
- package/lib/visibility.js +81 -0
- package/lib/web-routes.d.ts +29 -0
- package/lib/web-routes.js +84 -0
- package/locale/en.json +6 -0
- package/package.json +130 -0
package/lib/skill.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
/** Skill name shown to the model and usable as `/artifact-pages`. */
|
|
3
|
+
export declare const SKILL_NAME = "artifact-pages";
|
|
4
|
+
/**
|
|
5
|
+
* Split SKILL.md into its description and body.
|
|
6
|
+
* @param text - file content.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseSkill(text: string): {
|
|
9
|
+
description: string;
|
|
10
|
+
body: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Register the bundled skill provider.
|
|
14
|
+
* @param ctx - context exposing `skills`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function registerSkill(ctx: Context): Promise<void>;
|
package/lib/skill.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Bundled `artifact-pages` skill, registered at bundled precedence so user skills can override it. */
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { BUNDLED_SKILL_RANK } from '@deepseek-ai/dsh-skill';
|
|
5
|
+
/** Skill name shown to the model and usable as `/artifact-pages`. */
|
|
6
|
+
export const SKILL_NAME = 'artifact-pages';
|
|
7
|
+
const PROVIDER_NAME = 'dsh-gh-pages-artifacts';
|
|
8
|
+
const SKILL_URL = new URL(`../assets/${SKILL_NAME}/SKILL.md`, import.meta.url);
|
|
9
|
+
const RESOURCE_BASE = { kind: 'directory', path: fileURLToPath(new URL(`../assets/${SKILL_NAME}/`, import.meta.url)) };
|
|
10
|
+
const INVOCATION = { modelInvocable: true, userInvocable: true };
|
|
11
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u;
|
|
12
|
+
/**
|
|
13
|
+
* Split SKILL.md into its description and body.
|
|
14
|
+
* @param text - file content.
|
|
15
|
+
*/
|
|
16
|
+
export function parseSkill(text) {
|
|
17
|
+
const match = FRONTMATTER.exec(text);
|
|
18
|
+
if (match === null)
|
|
19
|
+
throw new Error(`${SKILL_NAME}/SKILL.md has no frontmatter`);
|
|
20
|
+
const line = /^description:\s*(.+)$/mu.exec(match[1] ?? '');
|
|
21
|
+
const description = line?.[1]?.trim().replace(/^(['"])(.*)\1$/u, '$2') ?? '';
|
|
22
|
+
if (description === '')
|
|
23
|
+
throw new Error(`${SKILL_NAME}/SKILL.md has no description`);
|
|
24
|
+
return { description, body: text.slice(match[0].length).trim() };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Register the bundled skill provider.
|
|
28
|
+
* @param ctx - context exposing `skills`.
|
|
29
|
+
*/
|
|
30
|
+
export async function registerSkill(ctx) {
|
|
31
|
+
const { description } = parseSkill(await readFile(SKILL_URL, 'utf8'));
|
|
32
|
+
const candidate = {
|
|
33
|
+
name: SKILL_NAME,
|
|
34
|
+
description,
|
|
35
|
+
invocation: INVOCATION,
|
|
36
|
+
provider: PROVIDER_NAME,
|
|
37
|
+
source: 'bundled',
|
|
38
|
+
resourceBase: RESOURCE_BASE,
|
|
39
|
+
rank: BUNDLED_SKILL_RANK,
|
|
40
|
+
locator: SKILL_URL,
|
|
41
|
+
};
|
|
42
|
+
const provider = {
|
|
43
|
+
name: PROVIDER_NAME,
|
|
44
|
+
list: () => Promise.resolve([candidate]),
|
|
45
|
+
async get() {
|
|
46
|
+
const { body } = parseSkill(await readFile(SKILL_URL, 'utf8'));
|
|
47
|
+
return {
|
|
48
|
+
name: SKILL_NAME,
|
|
49
|
+
description,
|
|
50
|
+
invocation: INVOCATION,
|
|
51
|
+
provider: PROVIDER_NAME,
|
|
52
|
+
source: 'bundled',
|
|
53
|
+
resourceBase: RESOURCE_BASE,
|
|
54
|
+
content: body,
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
ctx.skills.registerProvider(() => provider);
|
|
59
|
+
}
|
package/lib/store.d.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository-backed artifact store. The Pages branch is the single source of truth: every
|
|
3
|
+
* change is one atomic commit built with the Git Data API and applied with a fast-forward-only
|
|
4
|
+
* ref update, retried on a fresh snapshot when another writer got there first.
|
|
5
|
+
*/
|
|
6
|
+
import { type GitHubClient } from './github.js';
|
|
7
|
+
import { type Manifest } from './manifest.js';
|
|
8
|
+
/** Where artifacts live. */
|
|
9
|
+
export interface RepoTarget {
|
|
10
|
+
readonly owner: string;
|
|
11
|
+
readonly repo: string;
|
|
12
|
+
readonly branch: string;
|
|
13
|
+
/** Pages source folder, '' or 'docs'. */
|
|
14
|
+
readonly siteDir: string;
|
|
15
|
+
/** Folder under the site root that holds artifacts, '' for the root. */
|
|
16
|
+
readonly pathPrefix: string;
|
|
17
|
+
/**
|
|
18
|
+
* 'folder': many artifacts, each in `<siteDir>/<pathPrefix>/<id>/` (a shared repository).
|
|
19
|
+
* 'root': one artifact at the site root (a repository created for that artifact).
|
|
20
|
+
*/
|
|
21
|
+
readonly layout: 'folder' | 'root';
|
|
22
|
+
}
|
|
23
|
+
/** One file to write. Text goes inline in the tree; bytes become a base64 blob. */
|
|
24
|
+
export type FileWrite = {
|
|
25
|
+
readonly path: string;
|
|
26
|
+
readonly text: string;
|
|
27
|
+
} | {
|
|
28
|
+
readonly path: string;
|
|
29
|
+
readonly bytes: Uint8Array;
|
|
30
|
+
};
|
|
31
|
+
/** A change computed from one snapshot. */
|
|
32
|
+
export interface Plan<T> {
|
|
33
|
+
readonly message: string;
|
|
34
|
+
readonly writes: readonly FileWrite[];
|
|
35
|
+
/** Repository-relative paths of existing files to delete. */
|
|
36
|
+
readonly deletes: readonly string[];
|
|
37
|
+
readonly manifest: Manifest;
|
|
38
|
+
readonly result: T;
|
|
39
|
+
}
|
|
40
|
+
/** How the site root relates to Jekyll. */
|
|
41
|
+
export type JekyllState =
|
|
42
|
+
/** `.nojekyll` is present: files are served exactly as committed. */
|
|
43
|
+
'disabled'
|
|
44
|
+
/** No `.nojekyll` and no Jekyll site files: the plugin adds `.nojekyll`. */
|
|
45
|
+
| 'add-nojekyll'
|
|
46
|
+
/** The site root is an existing Jekyll site; the plugin must not change how it builds. */
|
|
47
|
+
| 'jekyll-site';
|
|
48
|
+
/** Read-only view of the branch at one commit. */
|
|
49
|
+
export interface Snapshot {
|
|
50
|
+
/**
|
|
51
|
+
* 'missing-branch' when the branch does not exist yet and 'empty-repository' when the repository
|
|
52
|
+
* has no commits at all; reads then see nothing.
|
|
53
|
+
*/
|
|
54
|
+
readonly state: 'ready' | 'missing-branch' | 'empty-repository';
|
|
55
|
+
readonly headSha: string | undefined;
|
|
56
|
+
/** Root tree of the head commit. */
|
|
57
|
+
readonly treeSha: string | undefined;
|
|
58
|
+
readonly manifest: Manifest;
|
|
59
|
+
/**
|
|
60
|
+
* List every file under a repository-relative directory.
|
|
61
|
+
* @param dir - directory path.
|
|
62
|
+
* @returns repository-relative file paths, sorted.
|
|
63
|
+
*/
|
|
64
|
+
listFiles(dir: string): Promise<string[]>;
|
|
65
|
+
/**
|
|
66
|
+
* Read a file.
|
|
67
|
+
* @param path - repository-relative path.
|
|
68
|
+
* @returns the bytes, or undefined when absent.
|
|
69
|
+
*/
|
|
70
|
+
readFile(path: string): Promise<Uint8Array | undefined>;
|
|
71
|
+
/** Whether the site root serves files verbatim, needs `.nojekyll`, or is a Jekyll site. */
|
|
72
|
+
jekyll(): Promise<JekyllState>;
|
|
73
|
+
}
|
|
74
|
+
/** Result of a successful mutation. */
|
|
75
|
+
export interface Committed<T> {
|
|
76
|
+
readonly result: T;
|
|
77
|
+
readonly commitSha: string;
|
|
78
|
+
}
|
|
79
|
+
/** Options for {@link ArtifactStore}. */
|
|
80
|
+
export interface StoreOptions {
|
|
81
|
+
/** Optional commit author/committer. */
|
|
82
|
+
readonly author?: {
|
|
83
|
+
readonly name: string;
|
|
84
|
+
readonly email: string;
|
|
85
|
+
} | undefined;
|
|
86
|
+
/** Attempts before giving up on a branch that keeps moving. */
|
|
87
|
+
readonly maxAttempts?: number;
|
|
88
|
+
/** Sleep between attempts, replaceable in tests. */
|
|
89
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
/** Error for a repository the token cannot see. */
|
|
92
|
+
export declare class RepositoryNotFoundError extends Error {
|
|
93
|
+
readonly name = "RepositoryNotFoundError";
|
|
94
|
+
}
|
|
95
|
+
/** Error for a ref update GitHub refuses for good, such as a protected branch. */
|
|
96
|
+
export declare class BranchRejectedError extends Error {
|
|
97
|
+
readonly name = "BranchRejectedError";
|
|
98
|
+
}
|
|
99
|
+
/** Error for a site root that is an existing Jekyll site. */
|
|
100
|
+
export declare class JekyllSiteError extends Error {
|
|
101
|
+
readonly name = "JekyllSiteError";
|
|
102
|
+
}
|
|
103
|
+
/** Performs atomic commits on the Pages branch. Callers serialize writes within one process. */
|
|
104
|
+
export declare class ArtifactStore {
|
|
105
|
+
private readonly gh;
|
|
106
|
+
readonly target: RepoTarget;
|
|
107
|
+
private readonly options;
|
|
108
|
+
private readonly maxAttempts;
|
|
109
|
+
private readonly sleep;
|
|
110
|
+
constructor(gh: GitHubClient, target: RepoTarget, options?: StoreOptions);
|
|
111
|
+
private get repoPath();
|
|
112
|
+
private get refReadPath();
|
|
113
|
+
/** Human name of the branch, for messages. */
|
|
114
|
+
get branchLabel(): string;
|
|
115
|
+
/**
|
|
116
|
+
* @param id - artifact id.
|
|
117
|
+
* @returns the repository-relative folder of one artifact.
|
|
118
|
+
*/
|
|
119
|
+
artifactDir(id: string): string;
|
|
120
|
+
/**
|
|
121
|
+
* List the files that belong to one artifact, relative to its folder. In the root layout the
|
|
122
|
+
* site root also holds the plugin's own bookkeeping files, which are not part of the artifact.
|
|
123
|
+
* @param snapshot - branch snapshot.
|
|
124
|
+
* @param id - artifact id.
|
|
125
|
+
*/
|
|
126
|
+
artifactFiles(snapshot: Snapshot, id: string): Promise<string[]>;
|
|
127
|
+
/**
|
|
128
|
+
* Take a consistent snapshot of the branch head. Never writes.
|
|
129
|
+
* @param signal - cancellation.
|
|
130
|
+
*/
|
|
131
|
+
snapshot(signal?: AbortSignal): Promise<Snapshot>;
|
|
132
|
+
/**
|
|
133
|
+
* Apply one change atomically. `build` runs against a fresh snapshot on every attempt, so a
|
|
134
|
+
* concurrent writer never loses its change and `build` can re-check its preconditions.
|
|
135
|
+
* @param build - computes the plan from a snapshot; may throw to abort.
|
|
136
|
+
* @param signal - cancellation.
|
|
137
|
+
* @returns the plan result and the new commit.
|
|
138
|
+
*/
|
|
139
|
+
mutate<T>(build: (snapshot: Snapshot) => Promise<Plan<T>>, signal?: AbortSignal): Promise<Committed<T>>;
|
|
140
|
+
private treeEntries;
|
|
141
|
+
/**
|
|
142
|
+
* Move the branch to a new commit if it still points at the snapshot head. A refusal while the
|
|
143
|
+
* branch has not moved (protected branch, ruleset, missing permission) is permanent.
|
|
144
|
+
*/
|
|
145
|
+
private fastForward;
|
|
146
|
+
private createBranch;
|
|
147
|
+
private currentHead;
|
|
148
|
+
private assertRepositoryVisible;
|
|
149
|
+
/** Give an empty repository its first commit so the Git Data API accepts it. */
|
|
150
|
+
private initializeEmptyRepository;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* @param target - repository target.
|
|
154
|
+
* @returns the error for a site root that is an existing Jekyll site.
|
|
155
|
+
*/
|
|
156
|
+
export declare function jekyllSiteError(target: RepoTarget): JekyllSiteError;
|
|
157
|
+
/**
|
|
158
|
+
* Join path segments with '/', skipping empty ones.
|
|
159
|
+
* @param parts - segments or partial paths.
|
|
160
|
+
*/
|
|
161
|
+
export declare function joinPath(...parts: string[]): string;
|
|
162
|
+
/**
|
|
163
|
+
* Decode strict UTF-8.
|
|
164
|
+
* @param bytes - encoded text.
|
|
165
|
+
* @param label - name used in the error.
|
|
166
|
+
*/
|
|
167
|
+
export declare function decodeUtf8(bytes: Uint8Array, label: string): string;
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository-backed artifact store. The Pages branch is the single source of truth: every
|
|
3
|
+
* change is one atomic commit built with the Git Data API and applied with a fast-forward-only
|
|
4
|
+
* ref update, retried on a fresh snapshot when another writer got there first.
|
|
5
|
+
*/
|
|
6
|
+
import { encodePath, GitHubApiError } from './github.js';
|
|
7
|
+
import { emptyManifest, MANIFEST_PATH, parseManifest, serializeManifest } from './manifest.js';
|
|
8
|
+
const NOJEKYLL = '.nojekyll';
|
|
9
|
+
const NOJEKYLL_TEXT = '# Disables Jekyll so GitHub Pages serves files exactly as committed.\n';
|
|
10
|
+
/** Files and folders that mark a Jekyll site. */
|
|
11
|
+
const JEKYLL_MARKERS = new Set(['_config.yml', '_config.yaml', '_config.toml', 'Gemfile', '_layouts', '_includes', '_posts', '_data', '_sass']);
|
|
12
|
+
/** Error for a repository the token cannot see. */
|
|
13
|
+
export class RepositoryNotFoundError extends Error {
|
|
14
|
+
name = 'RepositoryNotFoundError';
|
|
15
|
+
}
|
|
16
|
+
/** Error for a ref update GitHub refuses for good, such as a protected branch. */
|
|
17
|
+
export class BranchRejectedError extends Error {
|
|
18
|
+
name = 'BranchRejectedError';
|
|
19
|
+
}
|
|
20
|
+
/** Error for a site root that is an existing Jekyll site. */
|
|
21
|
+
export class JekyllSiteError extends Error {
|
|
22
|
+
name = 'JekyllSiteError';
|
|
23
|
+
}
|
|
24
|
+
/** Performs atomic commits on the Pages branch. Callers serialize writes within one process. */
|
|
25
|
+
export class ArtifactStore {
|
|
26
|
+
gh;
|
|
27
|
+
target;
|
|
28
|
+
options;
|
|
29
|
+
maxAttempts;
|
|
30
|
+
sleep;
|
|
31
|
+
constructor(gh, target, options = {}) {
|
|
32
|
+
this.gh = gh;
|
|
33
|
+
this.target = target;
|
|
34
|
+
this.options = options;
|
|
35
|
+
this.maxAttempts = options.maxAttempts ?? 5;
|
|
36
|
+
this.sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
37
|
+
}
|
|
38
|
+
get repoPath() {
|
|
39
|
+
return `/repos/${encodeURIComponent(this.target.owner)}/${encodeURIComponent(this.target.repo)}`;
|
|
40
|
+
}
|
|
41
|
+
get refReadPath() {
|
|
42
|
+
return `${this.repoPath}/git/ref/heads/${encodePath(this.target.branch)}`;
|
|
43
|
+
}
|
|
44
|
+
/** Human name of the branch, for messages. */
|
|
45
|
+
get branchLabel() {
|
|
46
|
+
return `${this.target.branch} branch of ${this.target.owner}/${this.target.repo}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* @param id - artifact id.
|
|
50
|
+
* @returns the repository-relative folder of one artifact.
|
|
51
|
+
*/
|
|
52
|
+
artifactDir(id) {
|
|
53
|
+
return this.target.layout === 'root' ? this.target.siteDir : joinPath(this.target.siteDir, this.target.pathPrefix, id);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* List the files that belong to one artifact, relative to its folder. In the root layout the
|
|
57
|
+
* site root also holds the plugin's own bookkeeping files, which are not part of the artifact.
|
|
58
|
+
* @param snapshot - branch snapshot.
|
|
59
|
+
* @param id - artifact id.
|
|
60
|
+
*/
|
|
61
|
+
async artifactFiles(snapshot, id) {
|
|
62
|
+
const dir = this.artifactDir(id);
|
|
63
|
+
const files = (await snapshot.listFiles(dir)).map(path => dir === '' ? path : path.slice(dir.length + 1));
|
|
64
|
+
if (this.target.layout === 'folder')
|
|
65
|
+
return files;
|
|
66
|
+
const reserved = new Set([NOJEKYLL, ...this.target.siteDir === '' ? [MANIFEST_PATH] : []]);
|
|
67
|
+
return files.filter(file => !reserved.has(file));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Take a consistent snapshot of the branch head. Never writes.
|
|
71
|
+
* @param signal - cancellation.
|
|
72
|
+
*/
|
|
73
|
+
async snapshot(signal) {
|
|
74
|
+
const ref = await this.gh.request('GET', this.refReadPath, { allowStatus: [404, 409], signal });
|
|
75
|
+
if (ref.status === 409) {
|
|
76
|
+
const message = ref.data?.message ?? '';
|
|
77
|
+
if (!/empty/i.test(message)) {
|
|
78
|
+
throw new Error(`Repository ${this.target.owner}/${this.target.repo} is unavailable (${message || 'HTTP 409'}); if it was just created, try again in a minute`);
|
|
79
|
+
}
|
|
80
|
+
return emptySnapshot('empty-repository');
|
|
81
|
+
}
|
|
82
|
+
if (ref.status === 404) {
|
|
83
|
+
await this.assertRepositoryVisible(signal);
|
|
84
|
+
return emptySnapshot('missing-branch');
|
|
85
|
+
}
|
|
86
|
+
const headSha = ref.data.object.sha;
|
|
87
|
+
const commit = await this.gh.request('GET', `${this.repoPath}/git/commits/${headSha}`, { signal });
|
|
88
|
+
const reader = new TreeReader(this.gh, this.repoPath, commit.data.tree.sha, signal);
|
|
89
|
+
const manifestBytes = await reader.readFile(MANIFEST_PATH);
|
|
90
|
+
const manifest = manifestBytes === undefined ? emptyManifest() : parseManifest(decodeUtf8(manifestBytes, MANIFEST_PATH));
|
|
91
|
+
const siteDir = this.target.siteDir;
|
|
92
|
+
return {
|
|
93
|
+
state: 'ready', headSha, treeSha: commit.data.tree.sha, manifest,
|
|
94
|
+
listFiles: dir => reader.listFiles(dir),
|
|
95
|
+
readFile: path => reader.readFile(path),
|
|
96
|
+
async jekyll() {
|
|
97
|
+
const names = await reader.listNames(siteDir);
|
|
98
|
+
if (names.includes(NOJEKYLL))
|
|
99
|
+
return 'disabled';
|
|
100
|
+
return names.some(name => JEKYLL_MARKERS.has(name)) ? 'jekyll-site' : 'add-nojekyll';
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Apply one change atomically. `build` runs against a fresh snapshot on every attempt, so a
|
|
106
|
+
* concurrent writer never loses its change and `build` can re-check its preconditions.
|
|
107
|
+
* @param build - computes the plan from a snapshot; may throw to abort.
|
|
108
|
+
* @param signal - cancellation.
|
|
109
|
+
* @returns the plan result and the new commit.
|
|
110
|
+
*/
|
|
111
|
+
async mutate(build, signal) {
|
|
112
|
+
let lastConflict = '';
|
|
113
|
+
for (let attempt = 0; attempt < this.maxAttempts; attempt++) {
|
|
114
|
+
signal?.throwIfAborted();
|
|
115
|
+
if (attempt > 0)
|
|
116
|
+
await this.sleep(Math.min(4000, 300 * 2 ** attempt) + Math.floor(Math.random() * 200));
|
|
117
|
+
const snapshot = await this.snapshot(signal);
|
|
118
|
+
if (snapshot.state === 'empty-repository') {
|
|
119
|
+
// Only a mutation, which runs after approval, may give an empty repository its first commit.
|
|
120
|
+
await this.initializeEmptyRepository(signal);
|
|
121
|
+
lastConflict = 'the repository was empty';
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const plan = await build(snapshot);
|
|
125
|
+
signal?.throwIfAborted();
|
|
126
|
+
const entries = await this.treeEntries(plan, snapshot, signal);
|
|
127
|
+
const parents = snapshot.headSha === undefined ? [] : [snapshot.headSha];
|
|
128
|
+
const baseTree = snapshot.treeSha;
|
|
129
|
+
const tree = await this.gh.request('POST', `${this.repoPath}/git/trees`, {
|
|
130
|
+
body: baseTree === undefined ? { tree: entries } : { base_tree: baseTree, tree: entries }, signal,
|
|
131
|
+
});
|
|
132
|
+
const commitBody = { message: plan.message, tree: tree.data.sha, parents };
|
|
133
|
+
if (this.options.author !== undefined) {
|
|
134
|
+
commitBody['author'] = { ...this.options.author };
|
|
135
|
+
commitBody['committer'] = { ...this.options.author };
|
|
136
|
+
}
|
|
137
|
+
const commit = await this.gh.request('POST', `${this.repoPath}/git/commits`, { body: commitBody, signal });
|
|
138
|
+
const commitSha = commit.data.sha;
|
|
139
|
+
signal?.throwIfAborted();
|
|
140
|
+
const moved = snapshot.headSha === undefined
|
|
141
|
+
? await this.createBranch(commitSha, signal)
|
|
142
|
+
: await this.fastForward(commitSha, snapshot.headSha, signal);
|
|
143
|
+
if (moved.kind === 'ok')
|
|
144
|
+
return { result: plan.result, commitSha };
|
|
145
|
+
lastConflict = moved.reason;
|
|
146
|
+
}
|
|
147
|
+
throw new Error(`The ${this.branchLabel} kept changing while publishing (${lastConflict}); try again`);
|
|
148
|
+
}
|
|
149
|
+
async treeEntries(plan, snapshot, signal) {
|
|
150
|
+
const entries = [];
|
|
151
|
+
const written = new Set();
|
|
152
|
+
for (const write of plan.writes) {
|
|
153
|
+
if (written.has(write.path))
|
|
154
|
+
throw new Error(`internal error: ${write.path} written twice`);
|
|
155
|
+
written.add(write.path);
|
|
156
|
+
if ('text' in write) {
|
|
157
|
+
entries.push({ path: write.path, mode: '100644', type: 'blob', content: write.text });
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const blob = await this.gh.request('POST', `${this.repoPath}/git/blobs`, {
|
|
161
|
+
body: { content: Buffer.from(write.bytes).toString('base64'), encoding: 'base64' }, signal,
|
|
162
|
+
});
|
|
163
|
+
entries.push({ path: write.path, mode: '100644', type: 'blob', sha: blob.data.sha });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const path of plan.deletes) {
|
|
167
|
+
if (written.has(path))
|
|
168
|
+
continue;
|
|
169
|
+
if (snapshot.headSha !== undefined)
|
|
170
|
+
entries.push({ path, mode: '100644', type: 'blob', sha: null });
|
|
171
|
+
}
|
|
172
|
+
entries.push({ path: MANIFEST_PATH, mode: '100644', type: 'blob', content: serializeManifest(plan.manifest) });
|
|
173
|
+
const jekyll = snapshot.state === 'missing-branch' ? 'add-nojekyll' : await snapshot.jekyll();
|
|
174
|
+
if (jekyll === 'jekyll-site')
|
|
175
|
+
throw jekyllSiteError(this.target);
|
|
176
|
+
if (jekyll === 'add-nojekyll') {
|
|
177
|
+
entries.push({ path: joinPath(this.target.siteDir, NOJEKYLL), mode: '100644', type: 'blob', content: NOJEKYLL_TEXT });
|
|
178
|
+
}
|
|
179
|
+
return entries;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Move the branch to a new commit if it still points at the snapshot head. A refusal while the
|
|
183
|
+
* branch has not moved (protected branch, ruleset, missing permission) is permanent.
|
|
184
|
+
*/
|
|
185
|
+
async fastForward(commitSha, expectedHead, signal) {
|
|
186
|
+
const refPath = `${this.repoPath}/git/refs/heads/${encodePath(this.target.branch)}`;
|
|
187
|
+
let refusal;
|
|
188
|
+
try {
|
|
189
|
+
const response = await this.gh.request('PATCH', refPath, {
|
|
190
|
+
body: { sha: commitSha, force: false }, allowStatus: [409, 422], retry: false, signal,
|
|
191
|
+
});
|
|
192
|
+
if (response.status === 200)
|
|
193
|
+
return { kind: 'ok' };
|
|
194
|
+
refusal = response.data?.message ?? `HTTP ${response.status}`;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (signal?.aborted === true)
|
|
198
|
+
throw error;
|
|
199
|
+
if (error instanceof GitHubApiError && error.status < 500)
|
|
200
|
+
throw new BranchRejectedError(`GitHub refused to update the ${this.branchLabel}: ${error.apiMessage}${permissionHint(error)}`);
|
|
201
|
+
// A lost response may hide a successful update: trust the ref, not the transport.
|
|
202
|
+
const head = await this.currentHead(signal);
|
|
203
|
+
if (head === commitSha)
|
|
204
|
+
return { kind: 'ok' };
|
|
205
|
+
return { kind: 'retry', reason: error instanceof Error ? error.message : String(error) };
|
|
206
|
+
}
|
|
207
|
+
const head = await this.currentHead(signal);
|
|
208
|
+
if (head === commitSha)
|
|
209
|
+
return { kind: 'ok' };
|
|
210
|
+
if (head === expectedHead)
|
|
211
|
+
throw new BranchRejectedError(`GitHub refused to update the ${this.branchLabel}: ${refusal}. Branch protection or rulesets may block direct pushes; use a branch without them.`);
|
|
212
|
+
return { kind: 'retry', reason: refusal };
|
|
213
|
+
}
|
|
214
|
+
async createBranch(commitSha, signal) {
|
|
215
|
+
let refusal;
|
|
216
|
+
try {
|
|
217
|
+
const response = await this.gh.request('POST', `${this.repoPath}/git/refs`, {
|
|
218
|
+
body: { ref: `refs/heads/${this.target.branch}`, sha: commitSha }, allowStatus: [422], retry: false, signal,
|
|
219
|
+
});
|
|
220
|
+
if (response.status === 201 || response.status === 200)
|
|
221
|
+
return { kind: 'ok' };
|
|
222
|
+
refusal = response.data?.message ?? 'HTTP 422';
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
if (signal?.aborted === true)
|
|
226
|
+
throw error;
|
|
227
|
+
if (error instanceof GitHubApiError && error.status < 500)
|
|
228
|
+
throw new BranchRejectedError(`GitHub refused to create the ${this.branchLabel}: ${error.apiMessage}${permissionHint(error)}`);
|
|
229
|
+
const head = await this.currentHead(signal);
|
|
230
|
+
if (head === commitSha)
|
|
231
|
+
return { kind: 'ok' };
|
|
232
|
+
return { kind: 'retry', reason: error instanceof Error ? error.message : String(error) };
|
|
233
|
+
}
|
|
234
|
+
const head = await this.currentHead(signal);
|
|
235
|
+
if (head === commitSha)
|
|
236
|
+
return { kind: 'ok' };
|
|
237
|
+
if (head === undefined)
|
|
238
|
+
throw new BranchRejectedError(`GitHub refused to create the ${this.branchLabel}: ${refusal}`);
|
|
239
|
+
return { kind: 'retry', reason: 'the branch was created concurrently' };
|
|
240
|
+
}
|
|
241
|
+
async currentHead(signal) {
|
|
242
|
+
const ref = await this.gh.request('GET', this.refReadPath, { allowStatus: [404, 409], signal });
|
|
243
|
+
return ref.status === 200 ? ref.data.object.sha : undefined;
|
|
244
|
+
}
|
|
245
|
+
async assertRepositoryVisible(signal) {
|
|
246
|
+
const repo = await this.gh.request('GET', this.repoPath, { allowStatus: [404], signal });
|
|
247
|
+
if (repo.status === 404) {
|
|
248
|
+
throw new RepositoryNotFoundError(`Repository ${this.target.owner}/${this.target.repo} was not found, or the token cannot access it. Ask the user to create it and give the token Contents read/write access (see the dsh-gh-pages-artifacts README); do not run setup commands yourself.`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** Give an empty repository its first commit so the Git Data API accepts it. */
|
|
252
|
+
async initializeEmptyRepository(signal) {
|
|
253
|
+
const path = joinPath(this.target.siteDir, NOJEKYLL);
|
|
254
|
+
const response = await this.gh.request('PUT', `${this.repoPath}/contents/${encodePath(path)}`, {
|
|
255
|
+
body: {
|
|
256
|
+
message: 'Initialize repository for dsh artifacts',
|
|
257
|
+
content: Buffer.from(NOJEKYLL_TEXT).toString('base64'),
|
|
258
|
+
...this.options.author === undefined ? {} : { author: { ...this.options.author }, committer: { ...this.options.author } },
|
|
259
|
+
},
|
|
260
|
+
allowStatus: [409, 422], retry: false, signal,
|
|
261
|
+
});
|
|
262
|
+
if (response.status === 201 || response.status === 200)
|
|
263
|
+
return;
|
|
264
|
+
const message = response.data?.message ?? `HTTP ${response.status}`;
|
|
265
|
+
// Another writer initialized it first; the next snapshot sees its commit.
|
|
266
|
+
if (response.status === 422 && /sha|already exists/i.test(message))
|
|
267
|
+
return;
|
|
268
|
+
if (response.status === 409)
|
|
269
|
+
return;
|
|
270
|
+
throw new Error(`Could not initialize the empty repository ${this.target.owner}/${this.target.repo}: ${message}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function emptySnapshot(state) {
|
|
274
|
+
return {
|
|
275
|
+
state, headSha: undefined, treeSha: undefined, manifest: emptyManifest(),
|
|
276
|
+
listFiles: () => Promise.resolve([]),
|
|
277
|
+
readFile: () => Promise.resolve(undefined),
|
|
278
|
+
jekyll: () => Promise.resolve('add-nojekyll'),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* @param target - repository target.
|
|
283
|
+
* @returns the error for a site root that is an existing Jekyll site.
|
|
284
|
+
*/
|
|
285
|
+
export function jekyllSiteError(target) {
|
|
286
|
+
const where = `${target.owner}/${target.repo} ${target.branch}:/${target.siteDir}`;
|
|
287
|
+
return new JekyllSiteError(`${where} is an existing Jekyll site (it has Jekyll configuration and no .nojekyll). Publishing artifacts there would change how the site builds, so the plugin will not write to it. Ask the user to use a dedicated repository or branch for artifacts.`);
|
|
288
|
+
}
|
|
289
|
+
function permissionHint(error) {
|
|
290
|
+
return error.acceptedPermissions === undefined ? '' : ` (the token needs: ${error.acceptedPermissions})`;
|
|
291
|
+
}
|
|
292
|
+
/** Lazily walks trees of one commit, caching every listing. */
|
|
293
|
+
class TreeReader {
|
|
294
|
+
gh;
|
|
295
|
+
repoPath;
|
|
296
|
+
rootTree;
|
|
297
|
+
signal;
|
|
298
|
+
listings = new Map();
|
|
299
|
+
constructor(gh, repoPath, rootTree, signal) {
|
|
300
|
+
this.gh = gh;
|
|
301
|
+
this.repoPath = repoPath;
|
|
302
|
+
this.rootTree = rootTree;
|
|
303
|
+
this.signal = signal;
|
|
304
|
+
}
|
|
305
|
+
list(treeSha) {
|
|
306
|
+
let listing = this.listings.get(treeSha);
|
|
307
|
+
if (listing === undefined) {
|
|
308
|
+
listing = this.gh.request('GET', `${this.repoPath}/git/trees/${treeSha}`, { signal: this.signal })
|
|
309
|
+
.then(response => response.data.tree);
|
|
310
|
+
listing.catch(() => this.listings.delete(treeSha));
|
|
311
|
+
this.listings.set(treeSha, listing);
|
|
312
|
+
}
|
|
313
|
+
return listing;
|
|
314
|
+
}
|
|
315
|
+
async entry(path) {
|
|
316
|
+
if (path === '')
|
|
317
|
+
return { path: '', mode: '040000', type: 'tree', sha: this.rootTree };
|
|
318
|
+
let tree = this.rootTree;
|
|
319
|
+
const segments = path.split('/');
|
|
320
|
+
for (let index = 0; index < segments.length; index++) {
|
|
321
|
+
const found = (await this.list(tree)).find(item => item.path === segments[index]);
|
|
322
|
+
if (found === undefined)
|
|
323
|
+
return undefined;
|
|
324
|
+
if (index === segments.length - 1)
|
|
325
|
+
return found;
|
|
326
|
+
if (found.type !== 'tree')
|
|
327
|
+
return undefined;
|
|
328
|
+
tree = found.sha;
|
|
329
|
+
}
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
async readFile(path) {
|
|
333
|
+
const found = await this.entry(path);
|
|
334
|
+
if (found === undefined || found.type !== 'blob')
|
|
335
|
+
return undefined;
|
|
336
|
+
const blob = await this.gh.request('GET', `${this.repoPath}/git/blobs/${found.sha}`, { signal: this.signal });
|
|
337
|
+
if (blob.data.encoding !== 'base64')
|
|
338
|
+
return new TextEncoder().encode(blob.data.content);
|
|
339
|
+
return new Uint8Array(Buffer.from(blob.data.content, 'base64'));
|
|
340
|
+
}
|
|
341
|
+
async listNames(dir) {
|
|
342
|
+
const found = await this.entry(dir);
|
|
343
|
+
if (found === undefined || found.type !== 'tree')
|
|
344
|
+
return [];
|
|
345
|
+
return (await this.list(found.sha)).map(item => item.path);
|
|
346
|
+
}
|
|
347
|
+
async listFiles(dir) {
|
|
348
|
+
const found = await this.entry(dir);
|
|
349
|
+
if (found === undefined || found.type !== 'tree')
|
|
350
|
+
return [];
|
|
351
|
+
const tree = await this.gh.request('GET', `${this.repoPath}/git/trees/${found.sha}?recursive=1`, { signal: this.signal });
|
|
352
|
+
if (tree.data.truncated)
|
|
353
|
+
throw new Error(`Folder ${dir} has too many files to list`);
|
|
354
|
+
return tree.data.tree
|
|
355
|
+
.filter(item => item.type === 'blob')
|
|
356
|
+
.map(item => joinPath(dir, item.path))
|
|
357
|
+
.sort();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Join path segments with '/', skipping empty ones.
|
|
362
|
+
* @param parts - segments or partial paths.
|
|
363
|
+
*/
|
|
364
|
+
export function joinPath(...parts) {
|
|
365
|
+
return parts.filter(part => part !== '').join('/');
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Decode strict UTF-8.
|
|
369
|
+
* @param bytes - encoded text.
|
|
370
|
+
* @param label - name used in the error.
|
|
371
|
+
*/
|
|
372
|
+
export function decodeUtf8(bytes, label) {
|
|
373
|
+
try {
|
|
374
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
throw new Error(`${label} is not UTF-8 text`);
|
|
378
|
+
}
|
|
379
|
+
}
|
package/lib/tools.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Agent-facing tool definitions. */
|
|
2
|
+
import { type ToolDefinition } from '@deepseek-ai/dsh-tools';
|
|
3
|
+
import type { ArtifactsRuntime } from './service.js';
|
|
4
|
+
export { ALL_TOOLS, DELETE_TOOL, LIST_TOOL, MUTATING_TOOLS, PUBLISH_TOOL, READ_TOOL, REPOSITORY_TOOL, STATUS_TOOL } from './names.js';
|
|
5
|
+
/**
|
|
6
|
+
* Build the artifact tools around one runtime.
|
|
7
|
+
* @param runtime - shared operations.
|
|
8
|
+
* @returns tool definitions ready for `ctx.tools.register`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createTools(runtime: ArtifactsRuntime): ToolDefinition[];
|