@scaffold-llc/sdk 0.0.0-stage → 0.2.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SPINZ BUSINESS SOLUTIONS LTD
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,133 @@
1
- # Temporary Holding Version
1
+ # @scaffold-llc/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The TypeScript SDK for [Scaffold](https://scaffold.llc). Every command your assistant can run over MCP is a typed method here and an HTTP route, generated from one command list. No runtime dependencies; it needs Node 20 or later, or any runtime with `fetch`.
4
+
5
+ ```bash
6
+ npm install @scaffold-llc/sdk
7
+ ```
8
+
9
+ ## Quickstart
10
+
11
+ About five minutes, from an empty folder to a task on your board and a new project with its first deploy live. Deploys need the Paid plan.
12
+
13
+ ### 1. Create a token
14
+
15
+ In Scaffold, create a token with Write access under Settings, then Apps and tokens, and copy the workspace id beside it. The token is shown once, so save both to your environment now.
16
+
17
+ ```bash
18
+ export SCAFFOLD_TOKEN=scf_live_...
19
+ export SCAFFOLD_WORKSPACE=wsp_...
20
+ ```
21
+
22
+ ### 2. Zip the app
23
+
24
+ A deploy runs a Node app with a `package.json` at the root of its zip, started by its `start` script or its `main` file, and goes live once it accepts connections on `PORT` within 30 seconds. In an empty folder, this makes one:
25
+
26
+ ```bash
27
+ mkdir app
28
+ echo '{ "main": "server.js" }' > app/package.json
29
+ echo 'require("node:http").createServer((req, res) => res.end("Pinecrest")).listen(process.env.PORT);' > app/server.js
30
+ (cd app && zip -r ../pinecrest-web.zip .)
31
+ ```
32
+
33
+ ### 3. Write the script
34
+
35
+ In the same folder, run `npm install @scaffold-llc/sdk` and save this as `ship.mts`:
36
+
37
+ ```ts
38
+ import { readFile } from "node:fs/promises";
39
+ import { setTimeout as sleep } from "node:timers/promises";
40
+ import { Scaffold } from "@scaffold-llc/sdk";
41
+
42
+ // Reads SCAFFOLD_TOKEN from the environment
43
+ const scaffold = new Scaffold();
44
+ const workspace_id = process.env.SCAFFOLD_WORKSPACE!;
45
+
46
+ // Plan: put the work on the board
47
+ const task = await scaffold.task.create({
48
+ workspace_id,
49
+ title: "Ship the booking page",
50
+ status: "doing",
51
+ labels: ["launch"],
52
+ });
53
+ console.log(`Task ${task.key} is on the board`);
54
+
55
+ // Project: create the project the work ships in
56
+ const project = await scaffold.project.create({
57
+ workspace_id,
58
+ name: "pinecrest-web",
59
+ });
60
+ console.log(`Project ${project.slug} is ready at ${project.hostname}`);
61
+
62
+ // Deploy: upload the app's code and deploy it
63
+ const archive = await readFile("pinecrest-web.zip");
64
+ await scaffold.source.upload({ project_id: project.id, archive });
65
+ const deploy = await scaffold.deploy.create({ project_id: project.id });
66
+
67
+ // Logs: print the build's steps until it is live or failed
68
+ let after: number | undefined;
69
+ for (;;) {
70
+ const page = await scaffold.deploy.logs({ deploy_id: deploy.id, kind: "build", after });
71
+ for (const line of page.lines) console.log(line.text);
72
+ if (page.done) break;
73
+ after = page.next;
74
+ await sleep(1000);
75
+ }
76
+ const built = await scaffold.deploy.get({ deploy_id: deploy.id });
77
+ if (built.status !== "live") throw new Error(`Deploy ${built.id} failed: ${built.failure}`);
78
+ console.log(`Deploy ${built.id} is live at ${built.url}`);
79
+
80
+ // Hand off: move the task to review
81
+ await scaffold.task.move({ task_id: task.id, column: "review" });
82
+ console.log("The task is in review");
83
+ ```
84
+
85
+ ### 4. Run it
86
+
87
+ ```bash
88
+ npx tsx ship.mts
89
+ ```
90
+
91
+ You should see the task and the project, the build's steps, the deploy's address, and the task in review. In the console the project is live, the task is in review, and the activity rail shows each change as yours, with Undo.
92
+
93
+ A project's name must be new in its workspace, so change `pinecrest-web` before you run the file again.
94
+
95
+ ## Usage
96
+
97
+ ```ts
98
+ import { Scaffold, ScaffoldError } from "@scaffold-llc/sdk";
99
+
100
+ const scaffold = new Scaffold({
101
+ token: process.env.SCAFFOLD_TOKEN, // the default
102
+ baseUrl: "https://scaffold.llc", // the default
103
+ timeout: 30_000, // milliseconds per request
104
+ maxRetries: 2, // for 429 and 5xx answers, with backoff
105
+ version: "2026-10-01", // the default, sent as Scaffold-Version
106
+ });
107
+ ```
108
+
109
+ - **Methods** are `scaffold.<resource>.<action>(input)`, named like the command: `scaffold.task.create()` is the `task_create` MCP action and `POST /v1/tasks`. Input is one object with the HTTP API's field names; path, query and body fields go where the route takes them.
110
+ - **Lists** like `scaffold.task.list()` answer one page of `data` with `has_more` and `next_cursor` when awaited. Loop with `for await` and the SDK fetches each next page as you reach it.
111
+ - **Errors** throw a `ScaffoldError` with the HTTP status, the error's `type`, `message`, `param`, `requestId` and `details`, and `retryAfter` in seconds when the answer asked for a wait.
112
+ - **Writes** take an idempotency key as a second argument, `{ idempotencyKey: "launch-2026-10-29" }`, so a retried write returns the first result instead of running twice. Without one the SDK sends its own, so its retries of a write never run it twice.
113
+ - **Types** for every schema in the API are exported by name, like `import type { Task, Run } from "@scaffold-llc/sdk"`.
114
+
115
+ The full guide is at [scaffold.llc/docs/sdk](https://scaffold.llc/docs/sdk), and every method is in the [command map](https://scaffold.llc/docs/commands).
116
+
117
+ ## Contributing
118
+
119
+ This repository mirrors `sdk/` of Scaffold's platform repository, where the API is built. `src/generated` comes from the platform's OpenAPI document and command catalog by `npm run generate`, which only runs there, and its CI fails when the committed output is stale. So a change to the API reaches this SDK in the same merge request that makes it.
120
+
121
+ Issues and pull requests are welcome here for the runtime in `src/client.ts`, the tests and the docs; we apply accepted changes upstream, and the next mirror carries them back.
122
+
123
+ **Versions.** Every mirror that changes `src/` or `package.json` publishes a release from GitHub Actions, with npm provenance. The version is `major.minor` from `package.json` and the workflow's run number as the patch, so a spec change that only adds methods or fields needs no bump. A change that removes or renames a method or field, or narrows a type, bumps the minor in `package.json` while the SDK is 0.x, and the major from 1.0, in the same merge request. Leave the patch at 0.
124
+
125
+ ```bash
126
+ npm ci
127
+ npm run typecheck
128
+ npm test
129
+ ```
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,67 @@
1
+ import { type ErrorCode, type Methods } from "./generated/methods.js";
2
+ /** The API version this SDK was built for. */
3
+ export declare const API_VERSION = "2026-10-01";
4
+ export interface ScaffoldOptions {
5
+ /** A token from Settings, then Apps and tokens. Defaults to `SCAFFOLD_TOKEN`. */
6
+ token?: string;
7
+ /** Defaults to `https://scaffold.llc`. */
8
+ baseUrl?: string;
9
+ /** Milliseconds per request. Defaults to `30000`. */
10
+ timeout?: number;
11
+ /** Retries for 429 and 5xx answers, with backoff. Defaults to `2`. */
12
+ maxRetries?: number;
13
+ /** The API version every request sends as `Scaffold-Version`. Defaults to the SDK's own, `2026-10-01`. */
14
+ version?: string;
15
+ }
16
+ export interface WriteOptions {
17
+ /** A retried write with the same key returns the first result instead of running twice. Defaults to a new key per call, which the SDK's own retries reuse. */
18
+ idempotencyKey?: string;
19
+ }
20
+ export interface Route {
21
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
22
+ path: string;
23
+ /** A read that answers a page of `data`, paged by `starting_after`. */
24
+ list?: boolean;
25
+ /** The input field sent as the raw body, a zip, instead of the JSON body. */
26
+ zip?: string;
27
+ /** A write that takes `Idempotency-Key`, so sending it again never runs it twice. */
28
+ idempotent?: boolean;
29
+ }
30
+ /** A failed call, with the same `type` as the HTTP error body. */
31
+ export declare class ScaffoldError extends Error {
32
+ readonly status: number;
33
+ /** `unexpected_response` when something other than Scaffold answered, like a proxy. */
34
+ readonly type: ErrorCode | "unexpected_response";
35
+ /** The input field at fault, like `title`. */
36
+ readonly param: string | undefined;
37
+ /** What support finds the failure by. */
38
+ readonly requestId: string | undefined;
39
+ readonly details: unknown;
40
+ /** Seconds the answer asked to wait before trying again. */
41
+ readonly retryAfter: number | undefined;
42
+ name: string;
43
+ constructor(status: number,
44
+ /** `unexpected_response` when something other than Scaffold answered, like a proxy. */
45
+ type: ErrorCode | "unexpected_response", message: string,
46
+ /** The input field at fault, like `title`. */
47
+ param: string | undefined,
48
+ /** What support finds the failure by. */
49
+ requestId: string | undefined, details: unknown,
50
+ /** Seconds the answer asked to wait before trying again. */
51
+ retryAfter: number | undefined);
52
+ }
53
+ /** One page when awaited; with `for await`, every item, fetching each next page as you reach it. */
54
+ export declare class List<Page, Item> implements PromiseLike<Page>, AsyncIterable<Item> {
55
+ #private;
56
+ private readonly fetchPage;
57
+ constructor(fetchPage: (startingAfter?: string) => Promise<Page>);
58
+ then<A = Page, B = never>(onFulfilled?: ((page: Page) => A | PromiseLike<A>) | null, onRejected?: ((reason: unknown) => B | PromiseLike<B>) | null): Promise<A | B>;
59
+ [Symbol.asyncIterator](): AsyncIterator<Item>;
60
+ }
61
+ export interface Scaffold extends Methods {
62
+ }
63
+ /** The Scaffold API client: `scaffold.<resource>.<action>(input)` for every public command. */
64
+ export declare class Scaffold {
65
+ #private;
66
+ constructor(options?: ScaffoldOptions);
67
+ }
package/dist/client.js ADDED
@@ -0,0 +1,138 @@
1
+ import { COMMANDS } from "./generated/methods.js";
2
+ /** The API version this SDK was built for. */
3
+ export const API_VERSION = "2026-10-01";
4
+ /** A failed call, with the same `type` as the HTTP error body. */
5
+ export class ScaffoldError extends Error {
6
+ status;
7
+ type;
8
+ param;
9
+ requestId;
10
+ details;
11
+ retryAfter;
12
+ name = "ScaffoldError";
13
+ constructor(status,
14
+ /** `unexpected_response` when something other than Scaffold answered, like a proxy. */
15
+ type, message,
16
+ /** The input field at fault, like `title`. */
17
+ param,
18
+ /** What support finds the failure by. */
19
+ requestId, details,
20
+ /** Seconds the answer asked to wait before trying again. */
21
+ retryAfter) {
22
+ super(message);
23
+ this.status = status;
24
+ this.type = type;
25
+ this.param = param;
26
+ this.requestId = requestId;
27
+ this.details = details;
28
+ this.retryAfter = retryAfter;
29
+ }
30
+ }
31
+ /** One page when awaited; with `for await`, every item, fetching each next page as you reach it. */
32
+ export class List {
33
+ fetchPage;
34
+ #first;
35
+ constructor(fetchPage) {
36
+ this.fetchPage = fetchPage;
37
+ }
38
+ then(onFulfilled, onRejected) {
39
+ this.#first ??= this.fetchPage();
40
+ return this.#first.then(onFulfilled, onRejected);
41
+ }
42
+ async *[Symbol.asyncIterator]() {
43
+ let page = await this;
44
+ for (;;) {
45
+ const { data, has_more, next_cursor } = page;
46
+ yield* data;
47
+ if (!has_more || typeof next_cursor !== "string")
48
+ return;
49
+ page = await this.fetchPage(next_cursor);
50
+ }
51
+ }
52
+ }
53
+ /** The Scaffold API client: `scaffold.<resource>.<action>(input)` for every public command. */
54
+ export class Scaffold {
55
+ #token;
56
+ #baseUrl;
57
+ #timeout;
58
+ #maxRetries;
59
+ #version;
60
+ constructor(options = {}) {
61
+ const env = globalThis.process?.env;
62
+ this.#token = options.token ?? env?.SCAFFOLD_TOKEN;
63
+ this.#baseUrl = (options.baseUrl ?? "https://scaffold.llc").replace(/\/+$/, "");
64
+ this.#timeout = options.timeout ?? 30_000;
65
+ this.#maxRetries = options.maxRetries ?? 2;
66
+ this.#version = options.version ?? API_VERSION;
67
+ for (const [resource, actions] of Object.entries(COMMANDS)) {
68
+ const methods = Object.entries(actions).map(([action, route]) => {
69
+ const call = (input = {}, options = {}) => (route.list ? new List((startingAfter) => this.#call(route, startingAfter === undefined ? input : { ...input, starting_after: startingAfter }, options)) : this.#call(route, input, options));
70
+ return [action, call];
71
+ });
72
+ Object.assign(this, { [resource]: Object.fromEntries(methods) });
73
+ }
74
+ }
75
+ async #call(route, input, options) {
76
+ const rest = { ...input };
77
+ const path = route.path.replace(/\{(\w+)\}/g, (_, name) => {
78
+ const value = rest[name];
79
+ if (value === undefined)
80
+ throw new TypeError(`${route.method} ${route.path} needs ${name}`);
81
+ delete rest[name];
82
+ return encodeURIComponent(String(value));
83
+ });
84
+ const url = new URL(this.#baseUrl + path);
85
+ const headers = { Accept: "application/json", "Scaffold-Version": this.#version };
86
+ if (this.#token)
87
+ headers.Authorization = `Bearer ${this.#token}`;
88
+ if (route.idempotent)
89
+ headers["Idempotency-Key"] = options.idempotencyKey ?? crypto.randomUUID();
90
+ let body;
91
+ if (route.method === "GET" || route.method === "DELETE") {
92
+ // A query string has no null, so a null field is left out like an undefined one.
93
+ for (const [key, value] of Object.entries(rest))
94
+ if (value !== undefined && value !== null)
95
+ url.searchParams.set(key, String(value));
96
+ }
97
+ else if (route.zip !== undefined) {
98
+ headers["Content-Type"] = "application/zip";
99
+ body = rest[route.zip];
100
+ }
101
+ else {
102
+ headers["Content-Type"] = "application/json";
103
+ body = JSON.stringify(rest);
104
+ }
105
+ for (let attempt = 0;; attempt++) {
106
+ const response = await fetch(url, { method: route.method, headers, body, signal: AbortSignal.timeout(this.#timeout) });
107
+ if (response.ok)
108
+ return response.headers.get("Content-Type")?.includes("json") ? response.json() : response.blob();
109
+ const error = await failure(response);
110
+ // A 429 or 503 never ran. A 501 answers the same until the platform changes. Any other 5xx
111
+ // is sent again only when that cannot run it twice.
112
+ const retryable = response.status === 429 || response.status === 503 || (response.status >= 500 && response.status !== 501 && (route.method === "GET" || route.idempotent));
113
+ if (!retryable || attempt >= this.#maxRetries)
114
+ throw error;
115
+ await new Promise((resolve) => setTimeout(resolve, (error.retryAfter ?? backoff(attempt)) * 1000));
116
+ }
117
+ }
118
+ }
119
+ /** Seconds before retry `attempt + 1` when the answer named no wait: about 0.5, 1, 2 and on, with jitter. */
120
+ const backoff = (attempt) => 0.5 * 2 ** attempt * (0.5 + Math.random() / 2);
121
+ async function failure(response) {
122
+ const text = await response.text();
123
+ const header = response.headers.get("Retry-After");
124
+ const retryAfter = header === null || Number.isNaN(Number(header)) ? undefined : Number(header);
125
+ let parsed;
126
+ try {
127
+ parsed = JSON.parse(text);
128
+ }
129
+ catch {
130
+ parsed = undefined;
131
+ }
132
+ const error = parsed?.error;
133
+ if (typeof error?.type === "string" && typeof error.message === "string") {
134
+ const text = (value) => (typeof value === "string" ? value : undefined);
135
+ return new ScaffoldError(response.status, error.type, error.message, text(error.param), text(error.request_id), error.details, retryAfter);
136
+ }
137
+ return new ScaffoldError(response.status, "unexpected_response", `HTTP ${response.status}: ${text.slice(0, 200)}`, undefined, undefined, undefined, retryAfter);
138
+ }