@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.
@@ -0,0 +1,7 @@
1
+ export type ErrorCode = "CONFIG" | "AUTH" | "HTTP" | "NETWORK" | "RESPONSE" | "VALIDATION" | "STATE";
2
+ /** Never includes HTTP bodies, cookies, passwords or ticket URLs. */
3
+ export declare class CodeSnippetsError extends Error {
4
+ readonly code: ErrorCode;
5
+ readonly status?: number | undefined;
6
+ constructor(code: ErrorCode, message: string, status?: number | undefined);
7
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,11 @@
1
+ /** Never includes HTTP bodies, cookies, passwords or ticket URLs. */
2
+ export class CodeSnippetsError extends Error {
3
+ code;
4
+ status;
5
+ constructor(code, message, status) {
6
+ super(message);
7
+ this.code = code;
8
+ this.status = status;
9
+ this.name = "CodeSnippetsError";
10
+ }
11
+ }
@@ -0,0 +1,4 @@
1
+ export { CodeSnippetsClient } from "./client.js";
2
+ export { CodeSnippetsError } from "./errors.js";
3
+ export type { ErrorCode } from "./errors.js";
4
+ export type { Authentication, ClientOptions, ListOptions, Snippet, SnippetInput, } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { CodeSnippetsClient } from "./client.js";
2
+ export { CodeSnippetsError } from "./errors.js";
@@ -0,0 +1,10 @@
1
+ export declare function checkedUrl(value: string, allowHttp?: boolean): URL;
2
+ /** In-memory session. Redirects may only visit configured origins. */
3
+ export declare class Session {
4
+ private readonly origins;
5
+ private readonly timeoutMs;
6
+ private readonly allowHttp;
7
+ private readonly cookies;
8
+ constructor(origins: Set<string>, timeoutMs: number, allowHttp: boolean);
9
+ request(input: URL, init?: RequestInit, follow?: boolean): Promise<Response>;
10
+ }
@@ -0,0 +1,98 @@
1
+ import { CookieJar } from "tough-cookie";
2
+ import { CodeSnippetsError } from "./errors.js";
3
+ export function checkedUrl(value, allowHttp = false) {
4
+ let url;
5
+ try {
6
+ url = new URL(value);
7
+ }
8
+ catch {
9
+ throw new CodeSnippetsError("CONFIG", "Invalid URL");
10
+ }
11
+ if (url.username ||
12
+ url.password ||
13
+ url.hash ||
14
+ !["https:", ...(allowHttp ? ["http:"] : [])].includes(url.protocol)) {
15
+ throw new CodeSnippetsError("CONFIG", "Use HTTPS URLs without credentials or fragments");
16
+ }
17
+ return url;
18
+ }
19
+ /** In-memory session. Redirects may only visit configured origins. */
20
+ export class Session {
21
+ origins;
22
+ timeoutMs;
23
+ allowHttp;
24
+ cookies = new CookieJar();
25
+ constructor(origins, timeoutMs, allowHttp) {
26
+ this.origins = origins;
27
+ this.timeoutMs = timeoutMs;
28
+ this.allowHttp = allowHttp;
29
+ }
30
+ async request(input, init = {}, follow = true) {
31
+ let url = input;
32
+ let method = init.method ?? "GET";
33
+ let body = init.body;
34
+ const headers = new Headers(init.headers);
35
+ const signal = AbortSignal.timeout(this.timeoutMs);
36
+ const explicitCookie = headers.get("cookie");
37
+ for (let step = 0; step <= 10; step++) {
38
+ checkedUrl(url.href, this.allowHttp);
39
+ if (!this.origins.has(url.origin))
40
+ throw new CodeSnippetsError("AUTH", "Redirect to an unconfigured origin refused");
41
+ const cookie = await this.cookies.getCookieString(url.href);
42
+ headers.delete("cookie");
43
+ const combinedCookie = [
44
+ cookie,
45
+ url.origin === input.origin ? explicitCookie : null,
46
+ ]
47
+ .filter(Boolean)
48
+ .join("; ");
49
+ if (combinedCookie)
50
+ headers.set("cookie", combinedCookie);
51
+ let response;
52
+ try {
53
+ response = await fetch(url, {
54
+ ...init,
55
+ method,
56
+ body: body ?? null,
57
+ headers,
58
+ redirect: "manual",
59
+ signal,
60
+ });
61
+ }
62
+ catch {
63
+ throw new CodeSnippetsError("NETWORK", "Request failed or timed out");
64
+ }
65
+ for (const value of response.headers.getSetCookie()) {
66
+ await this.cookies.setCookie(value, url.href, { ignoreError: true });
67
+ }
68
+ if (![301, 302, 303, 307, 308].includes(response.status))
69
+ return response;
70
+ const location = response.headers.get("location");
71
+ if (!follow || !location) {
72
+ await response.body?.cancel();
73
+ throw new CodeSnippetsError("AUTH", "Unexpected redirect; check the session and site URL", response.status);
74
+ }
75
+ const next = new URL(location, url);
76
+ // A 307/308 must never replay a password form to another origin.
77
+ if (next.origin !== url.origin) {
78
+ if (method !== "GET" &&
79
+ method !== "HEAD" &&
80
+ [307, 308].includes(response.status)) {
81
+ await response.body?.cancel();
82
+ throw new CodeSnippetsError("AUTH", "Cross-origin credential replay refused");
83
+ }
84
+ headers.delete("authorization");
85
+ headers.delete("x-wp-nonce");
86
+ }
87
+ if (response.status === 303 ||
88
+ ([301, 302].includes(response.status) && method === "POST")) {
89
+ method = "GET";
90
+ body = null;
91
+ headers.delete("content-type");
92
+ }
93
+ await response.body?.cancel();
94
+ url = next;
95
+ }
96
+ throw new CodeSnippetsError("AUTH", "Too many login redirects");
97
+ }
98
+ }
@@ -0,0 +1,60 @@
1
+ /** Writable Code Snippets fields. IDs belong to the destination site. */
2
+ export interface SnippetInput {
3
+ name: string;
4
+ code: string;
5
+ desc?: string;
6
+ scope?: string;
7
+ priority?: number;
8
+ tags?: string[];
9
+ active?: boolean;
10
+ shared_network?: boolean;
11
+ condition_id?: number;
12
+ locked?: boolean;
13
+ }
14
+ export interface Snippet extends SnippetInput {
15
+ id: number;
16
+ desc: string;
17
+ scope: string;
18
+ priority: number;
19
+ tags: string[];
20
+ active: boolean;
21
+ network: boolean;
22
+ trashed: boolean;
23
+ code_error?: unknown;
24
+ }
25
+ export type Authentication = {
26
+ type: "application-password";
27
+ username: string;
28
+ password: string;
29
+ } | {
30
+ type: "wordpress";
31
+ username: string;
32
+ password: string;
33
+ loginUrl?: string;
34
+ } | {
35
+ type: "cas";
36
+ username: string;
37
+ password: string;
38
+ loginUrl: string;
39
+ serviceUrl?: string;
40
+ entryUrl?: string;
41
+ } | {
42
+ type: "session";
43
+ cookie: string;
44
+ nonce: string;
45
+ };
46
+ export interface ClientOptions {
47
+ baseUrl: string;
48
+ auth: Authentication;
49
+ network?: boolean;
50
+ adminUrl?: string;
51
+ timeoutMs?: number;
52
+ /** Explicit HTTP opt-in for disposable local environments. */
53
+ allowInsecureHttp?: boolean;
54
+ }
55
+ export interface ListOptions {
56
+ page?: number;
57
+ perPage?: number;
58
+ search?: string;
59
+ status?: "all" | "active" | "inactive";
60
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/docs/api.md ADDED
@@ -0,0 +1,75 @@
1
+ # API and CLI
2
+
3
+ ## Library
4
+
5
+ All methods are asynchronous. `login()` initializes authentication headers or a
6
+ session. Application password validity is checked on the first REST request.
7
+ API methods initialize authentication automatically and share an in-progress login.
8
+
9
+ | Method | Result |
10
+ | --------------------------------------------- | --------------------------------------------------- |
11
+ | `list({ page?, perPage?, search?, status? })` | `Snippet[]` |
12
+ | `get(id)` | `Snippet` |
13
+ | `create({ name, code, ... })` | `Snippet`, inactive unless `active: true` |
14
+ | `update(id, changes)` | `Snippet` |
15
+ | `activate(id)` / `deactivate(id)` | `Snippet` fetched after the operation |
16
+ | `delete(id)` | Trashed snippet, or `null` after permanent deletion |
17
+ | `restore(id)` | `Snippet` fetched after restoration |
18
+
19
+ IDs are positive integers local to each destination. Writable fields: `name`,
20
+ `code`, `desc`, `scope`, `priority`, `tags`, `active`, `shared_network`, `condition_id`
21
+ and `locked`. `network` belongs to the client configuration. Additional scopes and
22
+ fields may require a specific plugin edition.
23
+
24
+ Without explicit pagination, Code Snippets 3.10.2 returns the complete collection.
25
+ With `page`/`perPage`, only that page is returned; there is no implicit pagination
26
+ loop. `perPage` must be between 1 and 100. `status` accepts `all`, `active`, `inactive`.
27
+
28
+ `update` reads remote state and preserves known fields you did not specify. Avoid
29
+ concurrent updates of the same snippet: GET/POST is not a transaction. If saving an
30
+ active snippet deactivates it without reporting a code error, the client attempts
31
+ to restore activation once and verifies the result. When changing from/to
32
+ `single-use`, `active` is omitted unless explicitly supplied, and execution is never
33
+ retried. An explicit single-use activation may return `active: false` because the
34
+ verification read already consumed the execution; verify its application-specific effect.
35
+
36
+ `delete` follows plugin semantics: trash first, then permanently delete if already
37
+ trashed. It never retries automatically.
38
+
39
+ `CodeSnippetsError` provides `code`, `message` and an optional HTTP `status`.
40
+ Codes: `CONFIG`, `VALIDATION`, `AUTH`, `HTTP`, `NETWORK`, `RESPONSE`, `STATE`.
41
+ Errors do not include HTTP bodies, tickets, cookies or PHP source returned by the
42
+ server. A network error during a write does not prove the write was not applied.
43
+
44
+ ## CLI
45
+
46
+ `wp-code-snippets --help` lists all arguments. The library accepts code as supplied;
47
+ with `--file`, the CLI normalizes CRLF and strips only boundary PHP tags.
48
+
49
+ ```sh
50
+ wp-code-snippets get 42 --env-file .env
51
+ wp-code-snippets pull 42 --output backup.php --env-file .env
52
+ wp-code-snippets update 42 --input changes.json --env-file .env --yes
53
+ wp-code-snippets activate 42 --env-file .env --yes
54
+ wp-code-snippets deactivate 42 --env-file .env --yes
55
+ wp-code-snippets delete 42 --env-file .env --yes
56
+ wp-code-snippets restore 42 --env-file .env --yes
57
+ ```
58
+
59
+ `--input` accepts a JSON object; `--input -` reads it from stdin, useful for Python
60
+ consumers. Explicit field arguments override JSON fields. `--output` never overwrites
61
+ an existing file. Without it, `pull` writes raw code to stdout. `push` aliases `update`
62
+ and always requires an ID.
63
+
64
+ `--dry-run` describes a write without applying it. It is not a signed plan and does
65
+ not detect remote changes. Consumers choose versions and files to publish. The CLI
66
+ does not publish entire directories, resolve ambiguous names or create Git commits.
67
+
68
+ ## Python consumers
69
+
70
+ Install the npm package in the consuming project. Python can invoke
71
+ `node node_modules/@erseco/code-snippets-client/dist/bin.js` with an argument list,
72
+ parse stdout JSON and check the exit status. Pass configuration through the
73
+ environment and changes through stdin (`--input -`); never interpolate passwords
74
+ or PHP into a shell command. Each invocation keeps one session; separate invocations
75
+ authenticate again.
@@ -0,0 +1,41 @@
1
+ # Architecture and compatibility
2
+
3
+ Initial decision, 2026-09-13. AI assistance: Codex.
4
+
5
+ One npm package contains the library and CLI. `src/client.ts` adapts the REST API,
6
+ `src/auth.ts` handles authentication, and `src/session.ts` handles transport, cookies
7
+ and redirects. The CLI lives in `src/cli.ts`, with its entry point in `src/bin.ts`.
8
+
9
+ The client uses Node's native `fetch` and TypeScript compiled with `tsc`, without
10
+ a bundler. `tough-cookie` implements cookie rules and `cheerio/slim` parses HTML
11
+ forms, avoiding custom cookie and attribute parsers. The package is ESM and Node-only.
12
+ It includes no plugin PHP source, institutional configuration or backup data.
13
+
14
+ Consumers own deployment selection and ordering. There is no generic provider layer,
15
+ server, UI, Git synchronization or telemetry. No application-specific WordPress
16
+ business rules belong in this client.
17
+
18
+ ## Verified contract
19
+
20
+ The reference is Code Snippets 3.10.2 installed from WordPress.org, specifically
21
+ `php/REST_API/Snippets/Snippets_REST_Controller.php`. The development tree may change
22
+ before release: [upstream controller](https://github.com/codesnippetspro/code-snippets/blob/core-beta/src/php/REST_API/Snippets/Snippets_REST_Controller.php).
23
+
24
+ - Requests use `?rest_route=/code-snippets/v1/snippets`, supporting plain permalinks.
25
+ - Mutations use POST, accepted by `WP_REST_Server::EDITABLE`.
26
+ - In 3.10.2, activation/deactivation serialize the model as `{}`. A subsequent GET
27
+ normalizes the result and verifies persisted state.
28
+ - Restoration returns HTTP 204; the client also fetches the snippet afterward.
29
+ - The first deletion trashes the snippet; the next permanently deletes it.
30
+ - HTTP 200 alone is not sufficient evidence of successful activation.
31
+
32
+ ## Test boundaries
33
+
34
+ Local integration covers WordPress 7.1 / PHP 8.3 / Code Snippets 3.10.2, WordPress
35
+ login, application passwords, permissions and PHP snippet operations. Subsite routing
36
+ and network parameters are tested with HTTP simulations. Initial integration does
37
+ not demonstrate a real multisite network or Pro features.
38
+
39
+ CAS tests simulate forms and callbacks based on Cassify's flow. They have not run
40
+ against a production Apereo/Cassify deployment, MFA or every possible CAS theme.
41
+ Extend the relevant tests before claiming additional compatibility.
@@ -0,0 +1,85 @@
1
+ # Authentication
2
+
3
+ Set `baseUrl` to the complete site or subsite URL, without query parameters.
4
+ `network: true` selects network snippets. Set `adminUrl` when the administration
5
+ path differs; it must use the same origin as WordPress.
6
+ HTTPS is required unless `allowInsecureHttp: true` explicitly enables local HTTP tests.
7
+
8
+ ## Application passwords
9
+
10
+ ```ts
11
+ const auth = {
12
+ type: "application-password" as const,
13
+ username: process.env.WP_USERNAME!,
14
+ password: process.env.WP_PASSWORD!,
15
+ };
16
+ ```
17
+
18
+ Use a WordPress application password, not the user's regular password. Application
19
+ passwords must be enabled, and the account needs the plugin's permissions.
20
+ [WordPress REST authentication](https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/).
21
+
22
+ ## WordPress login
23
+
24
+ `auth: { type: 'wordpress', username, password }` retrieves the `wp-login.php` form,
25
+ preserves cookies and obtains a nonce from the snippet administration page.
26
+ An optional `loginUrl` supports a custom login path on the same origin.
27
+ It does not run JavaScript or complete MFA, CAPTCHA or interactive SSO challenges.
28
+
29
+ ## CAS / Cassify
30
+
31
+ ```ts
32
+ import { CodeSnippetsClient } from "@erseco/code-snippets-client";
33
+
34
+ const client = new CodeSnippetsClient({
35
+ baseUrl: "https://wordpress.example/subsite",
36
+ auth: {
37
+ type: "cas",
38
+ username: process.env.WP_USERNAME!,
39
+ password: process.env.WP_PASSWORD!,
40
+ loginUrl: "https://login.example/cas/login",
41
+ },
42
+ });
43
+ ```
44
+
45
+ By default, the client opens WordPress `wp-login.php` and follows the redirect
46
+ configured by Cassify. It preserves hidden fields, including `execution` and `lt`
47
+ when present, submits `username`, `password` and `_eventId=submit`, then follows the
48
+ service ticket back to WordPress. **WordPress/Cassify validates the ticket**; this
49
+ library is an HTTP form client, not a CAS server or service-ticket validator.
50
+
51
+ - `entryUrl`: alternate entry point on the WordPress origin.
52
+ - `serviceUrl`: when set, starts directly at `loginUrl?service=...`. Only use a
53
+ callback accepted by your WordPress setup; some deployments need a PHP session
54
+ initialized through WordPress first.
55
+ - No images or scripts are executed, and no additional challenges are completed.
56
+ - Forms must use the conventional field names above. Custom field names, MFA,
57
+ dynamically generated forms and the CAS REST/TGT protocol are not supported.
58
+ - CI simulates login and callback without production credentials. It does not
59
+ certify universal compatibility with Apereo or Cassify deployments.
60
+
61
+ Reference flow:
62
+ [WP Cassify](https://github.com/WP-Cassify/wp-cassify-develop/blob/main/wp-cassify/classes/wp_cassify_plugin.php).
63
+
64
+ ## Existing sessions
65
+
66
+ `auth: { type: 'session', cookie, nonce }` accepts a session obtained externally,
67
+ including after an interactive login. These values are not persisted. The nonce
68
+ must belong to that session and the user must have the required permissions.
69
+
70
+ ## Session boundaries and failures
71
+
72
+ Cookies remain in memory using `tough-cookie`. Redirects are restricted to the
73
+ configured WordPress and CAS origins. A form cannot submit a password to another
74
+ origin; cross-origin 307/308 redirects cannot replay its body either. Cookies follow
75
+ domain and path rules: different ports do not isolate cookies.
76
+
77
+ REST requests do not follow redirects to login forms. An expired session fails;
78
+ create a new client to authenticate again. Writes with uncertain outcomes are not
79
+ retried automatically. `timeoutMs` defaults to 30,000. Redirects share their request's
80
+ timeout and are limited to ten hops.
81
+
82
+ The CLI only loads an explicit `--env-file .env`. Use `WP_AUTH=cas` with
83
+ `CAS_LOGIN_URL`, `WP_USERNAME` and `WP_PASSWORD`. Existing sessions use
84
+ `WP_AUTH=session`, `WP_COOKIE` and `WP_NONCE`. Optional variables are documented in
85
+ `.env.example` and `--help`. Never pass passwords as command-line arguments.
@@ -0,0 +1,49 @@
1
+ # Development and releases
2
+
3
+ Requirements: Node >=22.14, npm, and Docker for integration tests. `npm run` commands
4
+ also work through an agent on Windows; the Makefile is an optional shortcut.
5
+
6
+ ```sh
7
+ npm ci
8
+ npm run check
9
+ npm run test:package
10
+ npm run test:integration
11
+ ```
12
+
13
+ `check` runs formatting, strict TypeScript, Vitest with coverage and compilation.
14
+ HTTP tests use ephemeral loopback servers. `test:package` creates a tarball, validates
15
+ its file allowlist, installs it in a temporary directory and tests the installed
16
+ import and CLI. Run `npm run build` first when invoking the package test separately.
17
+ CI retains coverage reports as artifacts.
18
+
19
+ `test:integration` starts `.wp-env.json`, exercises CRUD, metadata, activation,
20
+ double evaluation, permissions, application passwords and single-use snippets. It
21
+ cleans up its snippets and stops the environment in `finally`. It never reads a
22
+ `WP_URL` environment variable. Ports are 8896 and 8897. `npm run wp:destroy` removes
23
+ this disposable environment.
24
+
25
+ Runtime and development dependencies are pinned with a lockfile. Overrides for
26
+ `qs`, `ws` and `ajv` fix transitive WordPress environment dependencies; remove them
27
+ once upstream resolves patched versions without overrides.
28
+
29
+ ## Releases
30
+
31
+ 1. Update the version and CHANGELOG. Run every check.
32
+ 2. Merge into `main` with passing CI.
33
+ 3. Create and push a tag named `v` followed by the exact package version.
34
+ 4. `publish.yml` repeats CI, installs and verifies the package, publishes that exact
35
+ tarball to npm and attaches it to a GitHub Release.
36
+
37
+ There is no per-commit publication or secondary registry: npm and GitHub Releases
38
+ cover installation and artifact downloads. Never reuse a published version number.
39
+
40
+ The workflow uses npm Trusted Publishing (OIDC). Configure these values in npm:
41
+
42
+ - Repository: `erseco/code-snippets-client`.
43
+ - Workflow: `publish.yml`.
44
+ - Environment: `npm`.
45
+
46
+ A new package may require an authenticated first publication before configuring its
47
+ trusted publisher. Never add an npm token to the repository or workflow. A repeated
48
+ publication only succeeds when that version has an identical tarball checksum;
49
+ different contents cause a failure.
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@erseco/code-snippets-client",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript client and CLI for the WordPress Code Snippets REST API, with WordPress and CAS authentication",
5
+ "license": "GPL-3.0-only",
6
+ "author": "Ernesto Serrano",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/erseco/code-snippets-client.git"
10
+ },
11
+ "homepage": "https://github.com/erseco/code-snippets-client#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/erseco/code-snippets-client/issues"
14
+ },
15
+ "keywords": [
16
+ "wordpress",
17
+ "code-snippets",
18
+ "rest-api",
19
+ "cas",
20
+ "cli",
21
+ "typescript"
22
+ ],
23
+ "type": "module",
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ },
32
+ "bin": {
33
+ "wp-code-snippets": "./dist/bin.js"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "LICENSE",
39
+ "docs"
40
+ ],
41
+ "sideEffects": false,
42
+ "engines": {
43
+ "node": ">=22.14"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc -p tsconfig.build.json",
50
+ "typecheck": "tsc --noEmit",
51
+ "format": "prettier --write .",
52
+ "format:check": "prettier --check .",
53
+ "test": "vitest run --coverage",
54
+ "test:integration": "node scripts/integration.mjs",
55
+ "check": "npm run format:check && npm run typecheck && npm test && npm run build",
56
+ "prepack": "npm run build",
57
+ "test:package": "node scripts/test-package.mjs",
58
+ "wp:start": "wp-env start",
59
+ "wp:stop": "wp-env stop",
60
+ "wp:destroy": "wp-env destroy"
61
+ },
62
+ "dependencies": {
63
+ "cheerio": "1.2.0",
64
+ "tough-cookie": "6.0.2"
65
+ },
66
+ "devDependencies": {
67
+ "@types/node": "22.20.2",
68
+ "@vitest/coverage-v8": "5.0.0",
69
+ "@wordpress/env": "11.15.0",
70
+ "prettier": "3.9.6",
71
+ "typescript": "7.0.2",
72
+ "vitest": "5.0.0"
73
+ },
74
+ "overrides": {
75
+ "qs": "^6.16.0",
76
+ "ws": "^8.21.3",
77
+ "ajv": "^8.20.0"
78
+ }
79
+ }