@erseco/code-snippets-client 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/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # Code Snippets Client
2
+
3
+ [![CI](https://github.com/erseco/code-snippets-client/actions/workflows/ci.yml/badge.svg)](https://github.com/erseco/code-snippets-client/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/@erseco/code-snippets-client)](https://www.npmjs.com/package/@erseco/code-snippets-client)
5
+ [![Publish](https://github.com/erseco/code-snippets-client/actions/workflows/publish.yml/badge.svg)](https://github.com/erseco/code-snippets-client/actions/workflows/publish.yml)
6
+ [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
7
+
8
+ A TypeScript client and CLI for the WordPress [Code Snippets](https://wordpress.org/plugins/code-snippets/)
9
+ REST API. List, create, update, activate, deactivate and delete snippets from Node.js,
10
+ using application passwords, WordPress login, existing sessions or CAS forms.
11
+ An independent, unofficial project. Not the WPCode plugin.
12
+
13
+ ## Install
14
+
15
+ Requires **Node.js 22.14 or later**. Ships as ESM with compiled JavaScript and types.
16
+
17
+ ```sh
18
+ npm install @erseco/code-snippets-client
19
+ ```
20
+
21
+ ```ts
22
+ import { CodeSnippetsClient } from "@erseco/code-snippets-client";
23
+
24
+ const client = new CodeSnippetsClient({
25
+ baseUrl: "https://wordpress.example",
26
+ auth: {
27
+ type: "application-password",
28
+ username: process.env.WP_USERNAME!,
29
+ password: process.env.WP_PASSWORD!,
30
+ },
31
+ });
32
+
33
+ const snippets = await client.list();
34
+ const snippet = await client.create({
35
+ name: "Demo",
36
+ code: "// PHP without <?php",
37
+ });
38
+ await client.update(snippet.id, { code: "// Updated code" });
39
+ // When you are ready to run it:
40
+ await client.activate(snippet.id);
41
+ ```
42
+
43
+ New snippets are **inactive by default**. Updates preserve unspecified fields and
44
+ remote activation state. `single-use` snippets are never implicitly rearmed.
45
+ The library does not load `.env`, write backups or run Git commands.
46
+
47
+ ## CLI for developers and agents
48
+
49
+ The CLI uses the same client. It only reads the explicitly selected `.env` file;
50
+ existing environment variables take precedence. No global installation needed.
51
+
52
+ ```sh
53
+ npx wp-code-snippets list --env-file .env
54
+ npx wp-code-snippets diff 42 --file snippets/demo.php --env-file .env
55
+ npx wp-code-snippets push 42 --file snippets/demo.php --env-file .env --yes
56
+ npx wp-code-snippets create --name Demo --file snippets/demo.php --env-file .env --yes
57
+ ```
58
+
59
+ Every write requires `--yes`; agents obtain authorization in the conversation.
60
+ `--dry-run` previews the supplied fields without connecting to WordPress. It does
61
+ not verify permissions, PHP syntax or remote conflicts. `diff` returns both code
62
+ versions and whether they match. Output is JSON except for `pull` and help.
63
+ Errors go to stderr and produce a nonzero exit code.
64
+
65
+ [API and CLI reference](docs/api.md) · [Authentication and CAS](docs/authentication.md) ·
66
+ [Architecture and limits](docs/architecture.md) · [Development and releases](docs/development.md)
67
+
68
+ ## Test
69
+
70
+ ```sh
71
+ npm ci
72
+ npm run check
73
+ npm run test:package
74
+ npm run test:integration # Docker required; starts and stops disposable WordPress
75
+ ```
76
+
77
+ CI runs on Linux, Windows and macOS. WordPress integration runs on Linux, pinned
78
+ to **WordPress 7.1, PHP 8.3 and Code Snippets 3.10.2**. CAS tests use a local HTTP
79
+ form/ticket/callback simulator; they do not certify every Apereo deployment or MFA.
80
+
81
+ Licensed under GPL-3.0-only. See [LICENSE](LICENSE).
package/dist/auth.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { Session } from "./session.js";
2
+ import type { Authentication } from "./types.js";
3
+ export declare function authenticate(session: Session, auth: Authentication, base: URL, admin: URL, allowHttp: boolean): Promise<Headers>;
package/dist/auth.js ADDED
@@ -0,0 +1,76 @@
1
+ import { load } from "cheerio/slim";
2
+ import { CodeSnippetsError } from "./errors.js";
3
+ import { checkedUrl } from "./session.js";
4
+ /** Submit a form on its expected origin, preserving hidden CAS fields. */
5
+ async function submitLogin(session, response, expected, fields, allowHttp) {
6
+ if (!response.ok || new URL(response.url).origin !== expected.origin)
7
+ throw new CodeSnippetsError("AUTH", "Login form not found on the configured origin");
8
+ const $ = load(await response.text());
9
+ const form = $('input[type="password"]').first().closest("form");
10
+ if (!form.length)
11
+ throw new CodeSnippetsError("AUTH", "Password form missing; interactive authentication may be required");
12
+ const action = checkedUrl(new URL(form.attr("action") || response.url, response.url).href, allowHttp);
13
+ if (action.origin !== expected.origin)
14
+ throw new CodeSnippetsError("AUTH", "Cross-origin login form refused");
15
+ const data = new URLSearchParams();
16
+ form.find("input[name]").each((_, element) => {
17
+ const field = $(element);
18
+ if (field.attr("type") === "hidden" && !field.is("[disabled]"))
19
+ data.set(field.attr("name"), field.attr("value") ?? "");
20
+ });
21
+ for (const [name, value] of Object.entries(fields))
22
+ data.set(name, value);
23
+ const result = await session.request(action, { method: "POST", body: data });
24
+ if (!result.ok) {
25
+ await result.body?.cancel();
26
+ throw new CodeSnippetsError("AUTH", "Login rejected", result.status);
27
+ }
28
+ await result.body?.cancel();
29
+ }
30
+ export async function authenticate(session, auth, base, admin, allowHttp) {
31
+ if (auth.type === "application-password") {
32
+ return new Headers({
33
+ authorization: `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString("base64")}`,
34
+ });
35
+ }
36
+ if (auth.type === "session")
37
+ return new Headers({ cookie: auth.cookie, "x-wp-nonce": auth.nonce });
38
+ const adminPage = new URL("admin.php?page=add-snippet", admin);
39
+ if (auth.type === "wordpress") {
40
+ const login = checkedUrl(auth.loginUrl ?? new URL("wp-login.php", base).href, allowHttp);
41
+ if (login.origin !== base.origin)
42
+ throw new CodeSnippetsError("CONFIG", "WordPress login must use the site origin");
43
+ await submitLogin(session, await session.request(login), login, {
44
+ log: auth.username,
45
+ pwd: auth.password,
46
+ "wp-submit": "Log In",
47
+ redirect_to: adminPage.href,
48
+ testcookie: "1",
49
+ }, allowHttp);
50
+ }
51
+ else {
52
+ const cas = checkedUrl(auth.loginUrl, allowHttp);
53
+ let entry = checkedUrl(auth.entryUrl ?? new URL("wp-login.php", base).href, allowHttp);
54
+ if (entry.origin !== base.origin)
55
+ throw new CodeSnippetsError("CONFIG", "CAS entry must use the WordPress origin");
56
+ if (auth.serviceUrl) {
57
+ const service = checkedUrl(auth.serviceUrl, allowHttp);
58
+ if (service.origin !== base.origin)
59
+ throw new CodeSnippetsError("CONFIG", "CAS service must use the WordPress origin");
60
+ cas.searchParams.set("service", service.href);
61
+ entry = cas;
62
+ }
63
+ await submitLogin(session, await session.request(entry), cas, { username: auth.username, password: auth.password, _eventId: "submit" }, allowHttp);
64
+ }
65
+ const page = await session.request(adminPage);
66
+ if (!page.ok || new URL(page.url).origin !== base.origin) {
67
+ await page.body?.cancel();
68
+ throw new CodeSnippetsError("AUTH", "WordPress session not established", page.status);
69
+ }
70
+ const html = await page.text();
71
+ const nonce = /createNonceMiddleware\s*\(\s*["']([a-f0-9]{10})["']/.exec(html)?.[1] ??
72
+ /["']nonce["']\s*:\s*["']([a-f0-9]{10})["']/.exec(html)?.[1];
73
+ if (!nonce)
74
+ throw new CodeSnippetsError("AUTH", "REST nonce missing; check login and snippet permissions");
75
+ return new Headers({ "x-wp-nonce": nonce });
76
+ }
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "./cli.js";
3
+ import { CodeSnippetsError } from "./errors.js";
4
+ try {
5
+ const result = await runCli(process.argv.slice(2));
6
+ process.stdout.write(typeof result === "string"
7
+ ? result
8
+ : JSON.stringify(result, null, 2) + "\n");
9
+ }
10
+ catch (error) {
11
+ const safe = error instanceof CodeSnippetsError
12
+ ? { code: error.code, message: error.message, status: error.status }
13
+ : {
14
+ code: "CLI",
15
+ message: "Invalid arguments, input file or environment; use --help",
16
+ };
17
+ process.stderr.write(JSON.stringify({ error: safe }) + "\n");
18
+ process.exitCode = 1;
19
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { ClientOptions } from "./types.js";
2
+ export declare const help = "wp-code-snippets <command> [id] [options]\n\nCommands: list, get, create, update, push, pull, diff, activate, deactivate, delete, restore\nOptions:\n --env-file PATH Explicit .env (never searched automatically)\n --file PATH PHP source for create/update/push/diff\n --input PATH JSON snippet fields for create/update (use - for stdin)\n --output PATH Save pull output without overwriting an existing file\n --name TEXT Snippet name for create/update\n --scope TEXT Snippet scope\n --priority N Execution priority\n --enable Explicit activation on create/update\n --disable Explicit deactivation on create/update\n --yes Required for every remote write\n --dry-run Return intended fields without writing\n --json JSON output (default; pull writes raw code)\n --help Show this help\n\nConfiguration: WP_URL, WP_AUTH=application-password|wordpress|cas|session,\nWP_USERNAME, WP_PASSWORD, WP_NETWORK=true|false, WP_ADMIN_URL,\nCAS_LOGIN_URL, CAS_SERVICE_URL, CAS_ENTRY_URL, WP_COOKIE, WP_NONCE.\nWP_ALLOW_HTTP=true is for disposable local tests only.\n";
3
+ export declare function clientOptions(env: Record<string, string | undefined>): ClientOptions;
4
+ export declare function normalizePhp(code: string): string;
5
+ export declare function runCli(args: string[], environment?: Record<string, string | undefined>): Promise<unknown>;
package/dist/cli.js ADDED
@@ -0,0 +1,219 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { parseArgs, parseEnv } from "node:util";
3
+ import { CodeSnippetsClient, validateSnippetInput } from "./client.js";
4
+ import { CodeSnippetsError } from "./errors.js";
5
+ export const help = `wp-code-snippets <command> [id] [options]
6
+
7
+ Commands: list, get, create, update, push, pull, diff, activate, deactivate, delete, restore
8
+ Options:
9
+ --env-file PATH Explicit .env (never searched automatically)
10
+ --file PATH PHP source for create/update/push/diff
11
+ --input PATH JSON snippet fields for create/update (use - for stdin)
12
+ --output PATH Save pull output without overwriting an existing file
13
+ --name TEXT Snippet name for create/update
14
+ --scope TEXT Snippet scope
15
+ --priority N Execution priority
16
+ --enable Explicit activation on create/update
17
+ --disable Explicit deactivation on create/update
18
+ --yes Required for every remote write
19
+ --dry-run Return intended fields without writing
20
+ --json JSON output (default; pull writes raw code)
21
+ --help Show this help
22
+
23
+ Configuration: WP_URL, WP_AUTH=application-password|wordpress|cas|session,
24
+ WP_USERNAME, WP_PASSWORD, WP_NETWORK=true|false, WP_ADMIN_URL,
25
+ CAS_LOGIN_URL, CAS_SERVICE_URL, CAS_ENTRY_URL, WP_COOKIE, WP_NONCE.
26
+ WP_ALLOW_HTTP=true is for disposable local tests only.
27
+ `;
28
+ function boolean(env, key) {
29
+ if (env[key] !== undefined && !["true", "false"].includes(env[key]))
30
+ throw new CodeSnippetsError("CONFIG", `${key} must be true or false`);
31
+ return env[key] === "true";
32
+ }
33
+ export function clientOptions(env) {
34
+ const type = env.WP_AUTH ?? "application-password";
35
+ const credentials = {
36
+ username: env.WP_USERNAME ?? "",
37
+ password: env.WP_PASSWORD ?? "",
38
+ };
39
+ let auth;
40
+ if (type === "application-password" || type === "wordpress")
41
+ auth = { type, ...credentials };
42
+ else if (type === "session")
43
+ auth = { type, cookie: env.WP_COOKIE ?? "", nonce: env.WP_NONCE ?? "" };
44
+ else if (type === "cas")
45
+ auth = {
46
+ type,
47
+ ...credentials,
48
+ loginUrl: env.CAS_LOGIN_URL ?? "",
49
+ ...(env.CAS_SERVICE_URL ? { serviceUrl: env.CAS_SERVICE_URL } : {}),
50
+ ...(env.CAS_ENTRY_URL ? { entryUrl: env.CAS_ENTRY_URL } : {}),
51
+ };
52
+ else
53
+ throw new CodeSnippetsError("CONFIG", "Unsupported WP_AUTH");
54
+ return {
55
+ baseUrl: env.WP_URL ?? "",
56
+ auth,
57
+ network: boolean(env, "WP_NETWORK"),
58
+ allowInsecureHttp: boolean(env, "WP_ALLOW_HTTP"),
59
+ ...(env.WP_ADMIN_URL ? { adminUrl: env.WP_ADMIN_URL } : {}),
60
+ };
61
+ }
62
+ export function normalizePhp(code) {
63
+ return code
64
+ .replace(/\r\n/g, "\n")
65
+ .replace(/^\uFEFF?\s*<\?php(?:\s|$)/, "")
66
+ .replace(/\?>\s*$/, "");
67
+ }
68
+ export async function runCli(args, environment = process.env) {
69
+ const { values, positionals } = parseArgs({
70
+ args,
71
+ allowPositionals: true,
72
+ strict: true,
73
+ options: {
74
+ "env-file": { type: "string" },
75
+ file: { type: "string" },
76
+ input: { type: "string" },
77
+ output: { type: "string" },
78
+ name: { type: "string" },
79
+ scope: { type: "string" },
80
+ priority: { type: "string" },
81
+ enable: { type: "boolean" },
82
+ disable: { type: "boolean" },
83
+ yes: { type: "boolean" },
84
+ "dry-run": { type: "boolean" },
85
+ json: { type: "boolean" },
86
+ help: { type: "boolean" },
87
+ },
88
+ });
89
+ if (values.help || !positionals.length)
90
+ return help;
91
+ const [command, idText, ...extra] = positionals;
92
+ const commands = [
93
+ "list",
94
+ "get",
95
+ "create",
96
+ "update",
97
+ "push",
98
+ "pull",
99
+ "diff",
100
+ "activate",
101
+ "deactivate",
102
+ "delete",
103
+ "restore",
104
+ ];
105
+ if (!commands.includes(command) || extra.length)
106
+ throw new CodeSnippetsError("VALIDATION", "Invalid command or arguments");
107
+ const needsId = !["list", "create"].includes(command);
108
+ const id = Number(idText);
109
+ if (needsId ? !Number.isSafeInteger(id) || id <= 0 : idText !== undefined)
110
+ throw new CodeSnippetsError("VALIDATION", "Provide an ID only for commands operating on an existing snippet");
111
+ const mutation = !["list", "get", "pull", "diff"].includes(command);
112
+ if (mutation && !values.yes && !values["dry-run"])
113
+ throw new CodeSnippetsError("VALIDATION", "Remote writes require --yes after reviewing the intended change");
114
+ if (values.enable && values.disable)
115
+ throw new CodeSnippetsError("VALIDATION", "--enable and --disable are mutually exclusive");
116
+ const writeFields = ["create", "update", "push"].includes(command);
117
+ if (!writeFields &&
118
+ (values.input ||
119
+ values.name ||
120
+ values.scope ||
121
+ values.priority ||
122
+ values.enable ||
123
+ values.disable))
124
+ throw new CodeSnippetsError("VALIDATION", "Snippet fields are only valid for create/update/push");
125
+ if (values.file && !writeFields && command !== "diff")
126
+ throw new CodeSnippetsError("VALIDATION", "--file is only valid for create/update/push/diff");
127
+ if (values.output && command !== "pull")
128
+ throw new CodeSnippetsError("VALIDATION", "--output is only valid for pull");
129
+ if (values["dry-run"] && !mutation)
130
+ throw new CodeSnippetsError("VALIDATION", "--dry-run is only valid for remote writes");
131
+ const env = values["env-file"]
132
+ ? {
133
+ ...parseEnv(await readFile(values["env-file"], "utf8")),
134
+ ...environment,
135
+ }
136
+ : environment;
137
+ const client = new CodeSnippetsClient(clientOptions(env));
138
+ let input = {};
139
+ if (values.input) {
140
+ let text;
141
+ if (values.input === "-") {
142
+ const chunks = [];
143
+ for await (const chunk of process.stdin)
144
+ chunks.push(Buffer.from(chunk));
145
+ text = Buffer.concat(chunks).toString("utf8");
146
+ }
147
+ else
148
+ text = await readFile(values.input, "utf8");
149
+ const parsed = JSON.parse(text);
150
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
151
+ throw new CodeSnippetsError("VALIDATION", "--input must contain a JSON object");
152
+ input = parsed;
153
+ }
154
+ if (values.file)
155
+ input.code = normalizePhp(await readFile(values.file, "utf8"));
156
+ if (values.name !== undefined)
157
+ input.name = values.name;
158
+ if (values.scope !== undefined)
159
+ input.scope = values.scope;
160
+ if (values.priority !== undefined)
161
+ input.priority = Number(values.priority);
162
+ if (values.enable || values.disable)
163
+ input.active = !!values.enable;
164
+ if (writeFields) {
165
+ validateSnippetInput(input);
166
+ if (command === "create" &&
167
+ (!input.name?.trim() || typeof input.code !== "string"))
168
+ throw new CodeSnippetsError("VALIDATION", "A name and code are required");
169
+ if (command !== "create" && !Object.keys(input).length)
170
+ throw new CodeSnippetsError("VALIDATION", "No changes supplied");
171
+ }
172
+ if (values["dry-run"])
173
+ return {
174
+ command,
175
+ id: needsId ? id : undefined,
176
+ changes: input,
177
+ applied: false,
178
+ };
179
+ switch (command) {
180
+ case "list":
181
+ return client.list();
182
+ case "get":
183
+ return client.get(id);
184
+ case "create":
185
+ return client.create(input);
186
+ case "update":
187
+ case "push":
188
+ return client.update(id, input);
189
+ case "activate":
190
+ return client.activate(id);
191
+ case "deactivate":
192
+ return client.deactivate(id);
193
+ case "delete":
194
+ return client.delete(id);
195
+ case "restore":
196
+ return client.restore(id);
197
+ case "diff": {
198
+ if (!values.file)
199
+ throw new CodeSnippetsError("VALIDATION", "diff requires --file");
200
+ const remote = await client.get(id);
201
+ return {
202
+ id,
203
+ equal: remote.code === input.code,
204
+ remote: remote.code,
205
+ local: input.code,
206
+ };
207
+ }
208
+ case "pull": {
209
+ const remote = await client.get(id);
210
+ if (!values.output)
211
+ return remote.code;
212
+ await writeFile(values.output, remote.code, {
213
+ encoding: "utf8",
214
+ flag: "wx",
215
+ });
216
+ return { id, output: values.output };
217
+ }
218
+ }
219
+ }
@@ -0,0 +1,25 @@
1
+ import type { ClientOptions, ListOptions, Snippet, SnippetInput } from "./types.js";
2
+ export declare function validateSnippetInput(input: Partial<SnippetInput>): void;
3
+ export declare class CodeSnippetsClient {
4
+ private readonly options;
5
+ private readonly base;
6
+ private readonly admin;
7
+ private readonly session;
8
+ private loginPromise;
9
+ constructor(options: ClientOptions);
10
+ /** Initialize authentication; API methods initialize it automatically. */
11
+ login(): Promise<void>;
12
+ private headers;
13
+ private request;
14
+ /** Without explicit pagination, the plugin returns the entire collection. */
15
+ list(options?: ListOptions): Promise<Snippet[]>;
16
+ get(id: number): Promise<Snippet>;
17
+ create(input: SnippetInput): Promise<Snippet>;
18
+ update(id: number, changes: Partial<SnippetInput>): Promise<Snippet>;
19
+ private checkState;
20
+ activate(id: number): Promise<Snippet>;
21
+ deactivate(id: number): Promise<Snippet>;
22
+ /** The plugin moves to trash first; deleting an already trashed snippet is permanent. */
23
+ delete(id: number): Promise<Snippet | null>;
24
+ restore(id: number): Promise<Snippet>;
25
+ }
package/dist/client.js ADDED
@@ -0,0 +1,233 @@
1
+ import { authenticate } from "./auth.js";
2
+ import { CodeSnippetsError } from "./errors.js";
3
+ import { checkedUrl, Session } from "./session.js";
4
+ const fields = [
5
+ "name",
6
+ "code",
7
+ "desc",
8
+ "scope",
9
+ "priority",
10
+ "tags",
11
+ "active",
12
+ "shared_network",
13
+ "condition_id",
14
+ "locked",
15
+ ];
16
+ function idPath(id) {
17
+ if (!Number.isSafeInteger(id) || id <= 0)
18
+ throw new CodeSnippetsError("VALIDATION", "Snippet ID must be a positive safe integer");
19
+ return String(id);
20
+ }
21
+ export function validateSnippetInput(input) {
22
+ if (!input || typeof input !== "object" || Array.isArray(input))
23
+ throw new CodeSnippetsError("VALIDATION", "Expected snippet fields");
24
+ for (const [key, value] of Object.entries(input)) {
25
+ if (!fields.includes(key))
26
+ throw new CodeSnippetsError("VALIDATION", "Unknown snippet field");
27
+ const valid = ["name", "code", "desc", "scope"].includes(key)
28
+ ? typeof value === "string"
29
+ : ["active", "shared_network", "locked"].includes(key)
30
+ ? typeof value === "boolean"
31
+ : key === "tags"
32
+ ? Array.isArray(value) && value.every((v) => typeof v === "string")
33
+ : Number.isSafeInteger(value) && Number(value) >= 0;
34
+ if (!valid)
35
+ throw new CodeSnippetsError("VALIDATION", "Invalid snippet field value");
36
+ }
37
+ }
38
+ function snippet(value) {
39
+ if (!value || typeof value !== "object")
40
+ throw new CodeSnippetsError("RESPONSE", "Expected a snippet object");
41
+ const s = value;
42
+ if (!Number.isSafeInteger(s.id) ||
43
+ Number(s.id) <= 0 ||
44
+ typeof s.name !== "string" ||
45
+ typeof s.code !== "string" ||
46
+ typeof s.active !== "boolean" ||
47
+ typeof s.scope !== "string" ||
48
+ typeof s.desc !== "string" ||
49
+ typeof s.network !== "boolean" ||
50
+ typeof s.trashed !== "boolean" ||
51
+ !Number.isSafeInteger(s.priority) ||
52
+ !Array.isArray(s.tags) ||
53
+ !s.tags.every((t) => typeof t === "string")) {
54
+ throw new CodeSnippetsError("RESPONSE", "Invalid snippet response");
55
+ }
56
+ return { ...s, code: s.code.replace(/\r\n/g, "\n") };
57
+ }
58
+ export class CodeSnippetsClient {
59
+ options;
60
+ base;
61
+ admin;
62
+ session;
63
+ loginPromise;
64
+ constructor(options) {
65
+ this.options = options;
66
+ const allowHttp = options.allowInsecureHttp ?? false;
67
+ this.base = checkedUrl(options.baseUrl, allowHttp);
68
+ if (this.base.search)
69
+ throw new CodeSnippetsError("CONFIG", "baseUrl must be a site URL without query parameters");
70
+ this.base.pathname = this.base.pathname.replace(/\/$/, "") + "/";
71
+ this.admin = checkedUrl(options.adminUrl ??
72
+ new URL(options.network ? "wp-admin/network/" : "wp-admin/", this.base)
73
+ .href, allowHttp);
74
+ this.admin.pathname = this.admin.pathname.replace(/\/$/, "") + "/";
75
+ if (this.admin.origin !== this.base.origin)
76
+ throw new CodeSnippetsError("CONFIG", "Admin URL must use the site origin");
77
+ const timeout = options.timeoutMs ?? 30000;
78
+ if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 2147483647)
79
+ throw new CodeSnippetsError("CONFIG", "Invalid timeout");
80
+ const auth = options.auth;
81
+ if (!auth ||
82
+ !["wordpress", "cas", "application-password", "session"].includes(auth.type))
83
+ throw new CodeSnippetsError("CONFIG", "Unsupported authentication type");
84
+ if (auth.type === "session"
85
+ ? !auth.cookie || !auth.nonce
86
+ : !auth.username || !auth.password)
87
+ throw new CodeSnippetsError("CONFIG", "Authentication credentials are required");
88
+ const origins = new Set([this.base.origin]);
89
+ if (auth.type === "cas")
90
+ origins.add(checkedUrl(auth.loginUrl, allowHttp).origin);
91
+ this.session = new Session(origins, timeout, allowHttp);
92
+ }
93
+ /** Initialize authentication; API methods initialize it automatically. */
94
+ async login() {
95
+ await this.headers();
96
+ }
97
+ headers() {
98
+ this.loginPromise ??= authenticate(this.session, this.options.auth, this.base, this.admin, this.options.allowInsecureHttp ?? false).catch((error) => {
99
+ this.loginPromise = undefined;
100
+ throw error;
101
+ });
102
+ return this.loginPromise;
103
+ }
104
+ async request(path, method = "GET", body, query = {}) {
105
+ const url = new URL(this.base);
106
+ url.searchParams.set("rest_route", `/code-snippets/v1/snippets${path ? "/" + path : ""}`);
107
+ url.searchParams.set("network", String(this.options.network ?? false));
108
+ for (const [key, value] of Object.entries(query))
109
+ url.searchParams.set(key, value);
110
+ const headers = new Headers(await this.headers());
111
+ headers.set("accept", "application/json");
112
+ if (body !== undefined)
113
+ headers.set("content-type", "application/json");
114
+ const response = await this.session.request(url, {
115
+ method,
116
+ headers,
117
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
118
+ }, false);
119
+ if (!response.ok) {
120
+ await response.body?.cancel();
121
+ throw new CodeSnippetsError(response.status === 401 || response.status === 403 ? "AUTH" : "HTTP", `WordPress request failed (HTTP ${response.status})`, response.status);
122
+ }
123
+ if (response.status === 204)
124
+ return null;
125
+ try {
126
+ return await response.json();
127
+ }
128
+ catch {
129
+ throw new CodeSnippetsError("RESPONSE", "Expected JSON from WordPress; check the site URL and session");
130
+ }
131
+ }
132
+ /** Without explicit pagination, the plugin returns the entire collection. */
133
+ async list(options = {}) {
134
+ const query = {};
135
+ for (const [key, value] of Object.entries(options)) {
136
+ if (key === "page" || key === "perPage") {
137
+ if (!Number.isSafeInteger(value) ||
138
+ Number(value) < 1 ||
139
+ (key === "perPage" && Number(value) > 100))
140
+ throw new CodeSnippetsError("VALIDATION", "Invalid pagination");
141
+ query[key === "perPage" ? "per_page" : key] = String(value);
142
+ }
143
+ else if (key === "search" && typeof value === "string")
144
+ query.search = value;
145
+ else if (key === "status" &&
146
+ ["all", "active", "inactive"].includes(String(value)))
147
+ query.status = String(value);
148
+ else
149
+ throw new CodeSnippetsError("VALIDATION", "Invalid list option");
150
+ }
151
+ const result = await this.request("", "GET", undefined, query);
152
+ if (!Array.isArray(result))
153
+ throw new CodeSnippetsError("RESPONSE", "Expected a snippet collection");
154
+ return result.map(snippet);
155
+ }
156
+ async get(id) {
157
+ return snippet(await this.request(idPath(id)));
158
+ }
159
+ async create(input) {
160
+ validateSnippetInput(input);
161
+ if (!input.name?.trim() || typeof input.code !== "string")
162
+ throw new CodeSnippetsError("VALIDATION", "A name and code are required");
163
+ const result = snippet(await this.request("", "POST", {
164
+ ...input,
165
+ active: input.active ?? false,
166
+ network: this.options.network ?? false,
167
+ }));
168
+ this.checkState(result, input.active ?? false, input.scope === "single-use");
169
+ return result;
170
+ }
171
+ async update(id, changes) {
172
+ idPath(id);
173
+ validateSnippetInput(changes);
174
+ if (!Object.keys(changes).length)
175
+ throw new CodeSnippetsError("VALIDATION", "No changes supplied");
176
+ const remote = await this.get(id);
177
+ const payload = {};
178
+ for (const field of fields)
179
+ if (remote[field] !== undefined)
180
+ payload[field] = remote[field];
181
+ Object.assign(payload, changes, { network: this.options.network ?? false });
182
+ const singleUse = remote.scope === "single-use" || payload.scope === "single-use";
183
+ if (singleUse && changes.active === undefined)
184
+ delete payload.active;
185
+ let result = snippet(await this.request(idPath(id), "POST", payload));
186
+ // Saving code can deactivate a valid snippet through redeclaration in that request.
187
+ // Never retry a single-use activation that may already have been consumed.
188
+ if (!singleUse &&
189
+ changes.active === undefined &&
190
+ remote.active &&
191
+ !result.active &&
192
+ !result.code_error) {
193
+ result = await this.activate(id);
194
+ }
195
+ this.checkState(result, changes.active ?? (singleUse ? undefined : remote.active), singleUse);
196
+ return result;
197
+ }
198
+ checkState(result, expected, singleUse = false) {
199
+ if (result.code_error)
200
+ throw new CodeSnippetsError("STATE", `WordPress reported a code error for snippet ${result.id}`);
201
+ if (expected !== undefined &&
202
+ !(singleUse && expected) &&
203
+ result.active !== expected)
204
+ throw new CodeSnippetsError("STATE", `WordPress did not preserve the requested state of snippet ${result.id}`);
205
+ }
206
+ async activate(id) {
207
+ await this.request(`${idPath(id)}/activate`, "POST", {
208
+ network: this.options.network ?? false,
209
+ });
210
+ const result = await this.get(id);
211
+ this.checkState(result, true, result.scope === "single-use");
212
+ return result;
213
+ }
214
+ async deactivate(id) {
215
+ await this.request(`${idPath(id)}/deactivate`, "POST", {
216
+ network: this.options.network ?? false,
217
+ });
218
+ const result = await this.get(id);
219
+ this.checkState(result, false);
220
+ return result;
221
+ }
222
+ /** The plugin moves to trash first; deleting an already trashed snippet is permanent. */
223
+ async delete(id) {
224
+ const result = await this.request(idPath(id), "DELETE");
225
+ return result === null ? null : snippet(result);
226
+ }
227
+ async restore(id) {
228
+ await this.request(`${idPath(id)}/restore`, "POST", {
229
+ network: this.options.network ?? false,
230
+ });
231
+ return this.get(id);
232
+ }
233
+ }