@samyx/preview-stacks-client 0.25.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 Sami Mishal
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 ADDED
@@ -0,0 +1,111 @@
1
+ # @samyx/preview-stacks-client
2
+
3
+ Typed API client for a [pstack](https://github.com/samishal1998/preview-stacks) control plane —
4
+ deployments, jobs, readiness, containers, logs and notifiers, plus webhook verification for the
5
+ receiving end.
6
+
7
+ Zero dependencies. Works in Node, Bun, Deno and the browser (it uses `fetch` and WebCrypto, nothing
8
+ else).
9
+
10
+ ```bash
11
+ bun add @samyx/preview-stacks-client # or npm / pnpm / yarn
12
+ ```
13
+
14
+ ## Use it
15
+
16
+ ```ts
17
+ import { createClient } from '@samyx/preview-stacks-client';
18
+
19
+ const pstack = createClient({
20
+ baseUrl: 'https://api.preview.example.com',
21
+ token: process.env.PSTACK_TOKEN, // machine token, or a personal pstack_pat_…
22
+ });
23
+
24
+ // Submit a spec and deploy it. Variables travel with EVERY call — `down` needs the same ones as
25
+ // `up`, or teardown targets a different stack than deploy created.
26
+ await pstack.deployments.put(`pr-${pr}`, { specName: 'shopfront', vars: { PR: String(pr) } });
27
+ const job = await pstack.deployments.up(`pr-${pr}`, { PR: pr });
28
+
29
+ // Wait for the commands to finish…
30
+ const finished = await pstack.waitForJob(job.id);
31
+ if (finished.state !== 'ok') throw new Error(`deploy ${finished.state}`);
32
+
33
+ // …and then for the app to actually be serving, which is a different question.
34
+ const ready = await pstack.waitForReady(`pr-${pr}`, { vars: { PR: pr } });
35
+ if (ready.state !== 'ready') {
36
+ console.error(ready.containers.filter((c) => c.failed).map((c) => `${c.name}: ${c.reason}`));
37
+ }
38
+ ```
39
+
40
+ ### Errors throw
41
+
42
+ Every non-2xx becomes a `PstackError` carrying the status and the server's own parsed body, so a
43
+ script either works or stops:
44
+
45
+ ```ts
46
+ import { PstackError } from '@samyx/preview-stacks-client';
47
+
48
+ try {
49
+ await pstack.deployments.down('shared-services');
50
+ } catch (e) {
51
+ if (e instanceof PstackError && e.status === 409) {
52
+ // Refused: tearing down a `kind: shared` stack destroys volumes every preview depends on.
53
+ await pstack.deployments.down('shared-services', { force: true });
54
+ } else throw e;
55
+ }
56
+ ```
57
+
58
+ The two waiters are the exception: `waitForJob` and `waitForReady` **return** a failed job or an
59
+ unready stack rather than throwing. The state is the answer, and a CI step usually wants to branch on
60
+ which one it got. They throw only when the wait itself fails.
61
+
62
+ ### Containers
63
+
64
+ ```ts
65
+ const rt = await pstack.deployments.runtime('pr-7', { PR: 7 });
66
+ for (const c of rt.containers.filter((c) => c.restartCount > 2)) {
67
+ await pstack.containers.restart('pr-7', c.name, { vars: { PR: 7 } });
68
+ }
69
+ const logs = await pstack.deployments.logs('pr-7', { service: 'web', tail: 500, timestamps: true, vars: { PR: 7 } });
70
+ ```
71
+
72
+ ### Verify a webhook
73
+
74
+ The half that lives in your receiver. It prevents the two mistakes that make a correct signature look
75
+ wrong: re-serialising the body before hashing, and skipping the staleness check the signed timestamp
76
+ exists for.
77
+
78
+ ```ts
79
+ import { verifyWebhook } from '@samyx/preview-stacks-client';
80
+
81
+ Bun.serve({
82
+ async fetch(req) {
83
+ const rawBody = await req.text(); // the untouched bytes, never a re-stringify
84
+ const v = await verifyWebhook({ secret: process.env.HOOK_SECRET!, rawBody, headers: req.headers });
85
+ if (!v.ok) return new Response(v.reason, { status: 401 });
86
+
87
+ const event = JSON.parse(rawBody);
88
+ if (v.redelivery) console.log('replay of', event.id); // dedupe on event.id
89
+ return new Response('ok');
90
+ },
91
+ });
92
+ ```
93
+
94
+ ## What it does not do
95
+
96
+ - **No log streaming.** `deployments.logs()` fetches a tail; following is an SSE endpoint
97
+ (`/api/deployments/:id/logs/stream`) and is left to `EventSource` or your own reader.
98
+ - **No terminal.** That is a WebSocket carrying a session cookie, which belongs to a browser.
99
+ - **No spec authoring helpers.** A spec is YAML you own; this client submits it verbatim.
100
+
101
+ Anything not wrapped is still reachable with the same auth and error handling:
102
+
103
+ ```ts
104
+ await pstack.request('GET', '/api/routing/live');
105
+ ```
106
+
107
+ ## Versioning
108
+
109
+ Released in lockstep with the server. A client is compatible with any server of the same minor or
110
+ newer — routes and payload fields are added, never renamed (the same contract the webhook events
111
+ carry). Full API reference: [docs/usage.md](https://github.com/samishal1998/preview-stacks/blob/main/docs/usage.md).
@@ -0,0 +1,270 @@
1
+ /**
2
+ * A typed client for a pstack control plane.
3
+ *
4
+ * ── WHY IT IS HAND-WRITTEN ───────────────────────────────────────────────────────────────────────
5
+ *
6
+ * There is no OpenAPI document to generate from, and the API is a plain `Bun.serve` router rather
7
+ * than a framework with an RPC client to borrow. Writing the ~30 calls out is smaller than adding a
8
+ * spec, a generator and a build step to produce the same thing — and it keeps this package at ZERO
9
+ * dependencies, which is what makes it safe to install into a CI job that also runs a deploy.
10
+ *
11
+ * The API is NOT changed to suit this client. Everything here is the existing surface, and the
12
+ * anti-drift measure is `test/client.test.ts`: it boots the real server and drives this client
13
+ * against it, so a route or field that moves fails a test instead of quietly lying in a `.d.ts`.
14
+ *
15
+ * ── IT THROWS ────────────────────────────────────────────────────────────────────────────────────
16
+ *
17
+ * Opposite of the web UI's client, on purpose. A page renders a 409 as content; a script wants the
18
+ * line it wrote to either work or stop. Every non-2xx becomes a `PstackError` carrying the status and
19
+ * the parsed body, so `catch (e) { if (e.status === 409) … }` is the whole error-handling story.
20
+ *
21
+ * ── WHAT IS WORTH INSTALLING THIS FOR ────────────────────────────────────────────────────────────
22
+ *
23
+ * `waitForJob`, `waitForReady` and `verifyWebhook` (below). They are the three things every user
24
+ * would otherwise re-write, and the two waiters are the ones that are easy to get subtly wrong —
25
+ * polling a job without a terminal-state list, or treating `readiness.state === 'watching'` as an
26
+ * error rather than "ask again".
27
+ */
28
+ import type { DeliveryRow, DeploymentRow, Health, HostVar, Job, Kind, Logs, NotifierRow, Readiness, Runtime, SpecMeta } from './types.ts';
29
+ export * from './types.ts';
30
+ /** A non-2xx answer. `body` is the parsed JSON, which is where the server's own message lives. */
31
+ export declare class PstackError extends Error {
32
+ readonly status: number;
33
+ readonly body: Record<string, unknown>;
34
+ readonly url: string;
35
+ constructor(status: number, url: string, body: Record<string, unknown>);
36
+ }
37
+ export type ClientOptions = {
38
+ /** e.g. `https://api.preview.example.com`. A trailing slash is fine. */
39
+ baseUrl: string;
40
+ /**
41
+ * `PSTACK_TOKEN` (the machine credential CI holds) or a personal token (`pstack_pat_…`).
42
+ *
43
+ * Optional only because a server with no token configured is loopback-only dev mode. Anywhere
44
+ * else, every route needs it.
45
+ */
46
+ token?: string;
47
+ /** Swap in for tests, a proxy, or an agent with custom TLS. Defaults to global `fetch`. */
48
+ fetch?: typeof fetch;
49
+ /** Per request, in ms. Deploys are started, never awaited, so this bounds the HTTP call only. */
50
+ timeoutMs?: number;
51
+ };
52
+ /** `{ PR: '7' }` → `?PR=7`. These are the spec's variables, and `down` needs the SAME ones as `up`. */
53
+ export type Vars = Record<string, string | number | undefined>;
54
+ export declare function createClient(opts: ClientOptions): {
55
+ /** Escape hatch: any route this client does not wrap yet, with auth and error handling applied. */
56
+ request: <T>(method: string, path: string, body?: unknown) => Promise<T>;
57
+ health: () => Promise<Health>;
58
+ deployments: {
59
+ list: (vars?: Vars) => Promise<DeploymentRow[]>;
60
+ get: (id: string, vars?: Vars) => Promise<DeploymentRow & {
61
+ spec?: unknown;
62
+ }>;
63
+ /**
64
+ * Submit or replace. `spec` is an inline spec file; `specName` references a stored one — one
65
+ * or the other, never both, because a deployment has one source of truth.
66
+ */
67
+ put: (id: string, body: {
68
+ spec?: string;
69
+ specName?: string;
70
+ compose?: string;
71
+ env?: Record<string, string>;
72
+ vars?: Record<string, string>;
73
+ }) => Promise<{
74
+ id: string;
75
+ kind: Kind;
76
+ stack: string;
77
+ }>;
78
+ /** Forget the RECORD. Never tears anything down, and is refused while containers exist. */
79
+ remove: (id: string, vars?: Vars) => Promise<{
80
+ removed: string;
81
+ stack: string;
82
+ }>;
83
+ /** Starts a job and returns immediately. Follow it with `waitForJob`. */
84
+ up: (id: string, vars?: Vars) => Promise<Job>;
85
+ verify: (id: string, vars?: Vars) => Promise<Job>;
86
+ /**
87
+ * `force` is required for a `kind: shared` stack and is refused without it — `down` runs
88
+ * `compose down -v`, which on a shared deployment destroys the volumes every tenant depends on.
89
+ */
90
+ down: (id: string, body?: {
91
+ verify?: boolean;
92
+ force?: boolean;
93
+ }, vars?: Vars) => Promise<Job>;
94
+ runtime: (id: string, vars?: Vars) => Promise<Runtime>;
95
+ /**
96
+ * Is it SERVING yet — separate from "did the deploy job succeed", which only means the
97
+ * commands ran. `wait` (seconds, max 60) long-polls and returns as soon as it is terminal.
98
+ */
99
+ readiness: (id: string, o?: {
100
+ wait?: number;
101
+ refresh?: boolean;
102
+ timeout?: number;
103
+ } & {
104
+ vars?: Vars;
105
+ }) => Promise<Readiness & {
106
+ id: string;
107
+ }>;
108
+ logs: (id: string, o?: {
109
+ tail?: number;
110
+ service?: string;
111
+ since?: string;
112
+ timestamps?: boolean;
113
+ vars?: Vars;
114
+ }) => Promise<Logs>;
115
+ };
116
+ /** One container, not the service and not the stack. The name must be one this deployment owns. */
117
+ containers: {
118
+ start: (id: string, name: string, vars?: Vars) => Promise<{
119
+ container: string;
120
+ action: string;
121
+ note: string;
122
+ }>;
123
+ stop: (id: string, name: string, o?: {
124
+ grace?: number;
125
+ vars?: Vars;
126
+ }) => Promise<{
127
+ container: string;
128
+ action: string;
129
+ note: string;
130
+ }>;
131
+ restart: (id: string, name: string, o?: {
132
+ grace?: number;
133
+ vars?: Vars;
134
+ }) => Promise<{
135
+ container: string;
136
+ action: string;
137
+ note: string;
138
+ }>;
139
+ };
140
+ jobs: {
141
+ list: () => Promise<Job[]>;
142
+ get: (jobId: string) => Promise<Job>;
143
+ /** Kills the command in flight. Undoes NOTHING — run `verify` afterwards. */
144
+ cancel: (jobId: string) => Promise<{
145
+ cancelled: string;
146
+ by: string;
147
+ warning: string;
148
+ }>;
149
+ };
150
+ specs: {
151
+ list: () => Promise<SpecMeta[]>;
152
+ get: (name: string) => Promise<SpecMeta & {
153
+ spec?: string;
154
+ compose?: string;
155
+ }>;
156
+ put: (name: string, body: {
157
+ spec: string;
158
+ compose?: string;
159
+ description?: string;
160
+ }) => Promise<{
161
+ name: string;
162
+ replaced: boolean;
163
+ }>;
164
+ /** Refused while a deployment still references it. */
165
+ remove: (name: string) => Promise<{
166
+ deleted: string;
167
+ }>;
168
+ };
169
+ notifiers: {
170
+ list: () => Promise<NotifierRow[]>;
171
+ /** The 201 is the ONLY time a signing secret exists in a response; `null` for types that do not sign. */
172
+ create: (body: {
173
+ name: string;
174
+ type?: string;
175
+ events: string[];
176
+ config: Record<string, unknown>;
177
+ }) => Promise<{
178
+ notifier: NotifierRow;
179
+ secret: string | null;
180
+ }>;
181
+ setEnabled: (id: number, enabled: boolean) => Promise<{
182
+ notifier: NotifierRow;
183
+ }>;
184
+ remove: (id: number) => Promise<{
185
+ deleted: number;
186
+ }>;
187
+ test: (id: number) => Promise<{
188
+ ok: boolean;
189
+ }>;
190
+ deliveries: (id: number) => Promise<{
191
+ deliveries: DeliveryRow[];
192
+ queued: number;
193
+ }>;
194
+ /** Replay a past delivery: same event id, fresh signature, `x-pstack-redelivery: 1`. */
195
+ redeliver: (id: number, deliveryId: number) => Promise<{
196
+ redelivered: number;
197
+ event: string;
198
+ eventId: string;
199
+ }>;
200
+ /** Event names and per-type form fields — what a UI builds its pickers from. */
201
+ meta: () => Promise<{
202
+ events: string[];
203
+ wildcard: string;
204
+ types: Array<{
205
+ kind: string;
206
+ label: string;
207
+ signs: boolean;
208
+ }>;
209
+ }>;
210
+ };
211
+ hostVars: {
212
+ list: () => Promise<HostVar[]>;
213
+ /** `secret: true` is one-way — no route returns the value again. */
214
+ put: (name: string, value: string, secret?: boolean) => Promise<{
215
+ name: string;
216
+ secret: boolean;
217
+ }>;
218
+ remove: (name: string) => Promise<{
219
+ deleted: string;
220
+ }>;
221
+ };
222
+ /**
223
+ * Poll a job until it stops running.
224
+ *
225
+ * Returns the finished job rather than throwing on a failed one: `failed`, `leaked` and
226
+ * `cancelled` are ANSWERS, and a CI step usually wants to branch on which. It throws only when
227
+ * the wait itself fails — a timeout, or the API being unreachable.
228
+ */
229
+ waitForJob(jobId: string, o?: {
230
+ intervalMs?: number;
231
+ timeoutMs?: number;
232
+ }): Promise<Job>;
233
+ /**
234
+ * Long-poll readiness until the stack settles.
235
+ *
236
+ * `watching` is not a failure and not an answer — it means "ask again", which is exactly the
237
+ * loop people get wrong by hand. Returns on `ready` / `failed` / `timedout`; the caller decides
238
+ * what each means for them.
239
+ */
240
+ waitForReady(id: string, o?: {
241
+ vars?: Vars;
242
+ timeoutMs?: number;
243
+ }): Promise<Readiness>;
244
+ };
245
+ export type PstackClient = ReturnType<typeof createClient>;
246
+ /**
247
+ * Verify a webhook this control plane sent you.
248
+ *
249
+ * The half of the integration that lives in the RECEIVER, which is why it ships here rather than
250
+ * staying prose in the docs: the two mistakes it prevents are re-serialising the body before hashing
251
+ * (the signature covers the exact bytes) and forgetting the staleness check (the timestamp is signed
252
+ * precisely so a replayed request cannot claim a fresh one).
253
+ *
254
+ * `rawBody` must be the untouched request body — not a re-stringified object.
255
+ */
256
+ export declare function verifyWebhook(args: {
257
+ secret: string;
258
+ rawBody: string;
259
+ headers: {
260
+ get(name: string): string | null;
261
+ } | Record<string, string | undefined>;
262
+ /** How old a delivery may be. Default 5 minutes; `0` disables the check. */
263
+ toleranceMs?: number;
264
+ now?: number;
265
+ }): Promise<{
266
+ ok: boolean;
267
+ reason?: string;
268
+ event?: string;
269
+ redelivery: boolean;
270
+ }>;
package/dist/index.js ADDED
@@ -0,0 +1,171 @@
1
+ // src/index.ts
2
+ class PstackError extends Error {
3
+ status;
4
+ body;
5
+ url;
6
+ constructor(status, url, body) {
7
+ super(typeof body.error === "string" ? body.error : `HTTP ${status} from ${url}`);
8
+ this.name = "PstackError";
9
+ this.status = status;
10
+ this.body = body;
11
+ this.url = url;
12
+ }
13
+ }
14
+ function qs(vars, extra = {}) {
15
+ const p = new URLSearchParams;
16
+ for (const [k, v] of Object.entries(vars ?? {}))
17
+ if (v !== undefined)
18
+ p.set(k, String(v));
19
+ for (const [k, v] of Object.entries(extra))
20
+ if (v !== undefined)
21
+ p.set(k, String(v));
22
+ const s = p.toString();
23
+ return s ? `?${s}` : "";
24
+ }
25
+ var enc = encodeURIComponent;
26
+ function createClient(opts) {
27
+ const base = opts.baseUrl.replace(/\/$/, "");
28
+ const doFetch = opts.fetch ?? globalThis.fetch;
29
+ async function request(method, path, body) {
30
+ const url = `${base}${path}`;
31
+ const headers = {};
32
+ if (opts.token)
33
+ headers.authorization = `Bearer ${opts.token}`;
34
+ if (body !== undefined)
35
+ headers["content-type"] = "application/json";
36
+ const res = await doFetch(url, {
37
+ method,
38
+ headers,
39
+ body: body === undefined ? undefined : JSON.stringify(body),
40
+ signal: opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined
41
+ });
42
+ const text = await res.text();
43
+ let parsed = null;
44
+ try {
45
+ parsed = text ? JSON.parse(text) : null;
46
+ } catch {
47
+ throw new PstackError(res.status, url, {
48
+ error: `expected JSON, got ${res.status}: ${text.slice(0, 300)}`
49
+ });
50
+ }
51
+ if (!res.ok)
52
+ throw new PstackError(res.status, url, parsed ?? {});
53
+ return parsed ?? {};
54
+ }
55
+ const get = (p) => request("GET", p);
56
+ const post = (p, b) => request("POST", p, b ?? {});
57
+ const put = (p, b) => request("PUT", p, b);
58
+ const del = (p) => request("DELETE", p);
59
+ const client = {
60
+ request,
61
+ health: () => get("/api/health"),
62
+ deployments: {
63
+ list: (vars) => get(`/api/deployments${qs(vars)}`).then((r) => r.deployments),
64
+ get: (id, vars) => get(`/api/deployments/${enc(id)}${qs(vars)}`),
65
+ put: (id, body) => put(`/api/deployments/${enc(id)}`, body),
66
+ remove: (id, vars) => del(`/api/deployments/${enc(id)}${qs(vars)}`),
67
+ up: (id, vars) => post(`/api/deployments/${enc(id)}/up${qs(vars)}`).then((r) => r.job),
68
+ verify: (id, vars) => post(`/api/deployments/${enc(id)}/verify${qs(vars)}`).then((r) => r.job),
69
+ down: (id, body = {}, vars) => post(`/api/deployments/${enc(id)}/down${qs(vars)}`, body).then((r) => r.job),
70
+ runtime: (id, vars) => get(`/api/deployments/${enc(id)}/runtime${qs(vars)}`),
71
+ readiness: (id, o = {}) => get(`/api/deployments/${enc(id)}/readiness${qs(o.vars, { wait: o.wait, refresh: o.refresh ? 1 : undefined, timeout: o.timeout })}`),
72
+ logs: (id, o = {}) => get(`/api/deployments/${enc(id)}/logs${qs(o.vars, {
73
+ tail: o.tail,
74
+ service: o.service,
75
+ since: o.since,
76
+ timestamps: o.timestamps ? 1 : undefined
77
+ })}`)
78
+ },
79
+ containers: {
80
+ start: (id, name, vars) => post(`/api/deployments/${enc(id)}/containers/${enc(name)}/start${qs(vars)}`),
81
+ stop: (id, name, o = {}) => post(`/api/deployments/${enc(id)}/containers/${enc(name)}/stop${qs(o.vars, { grace: o.grace })}`),
82
+ restart: (id, name, o = {}) => post(`/api/deployments/${enc(id)}/containers/${enc(name)}/restart${qs(o.vars, { grace: o.grace })}`)
83
+ },
84
+ jobs: {
85
+ list: () => get("/api/jobs").then((r) => r.jobs),
86
+ get: (jobId) => get(`/api/jobs/${enc(jobId)}`).then((r) => r.job),
87
+ cancel: (jobId) => post(`/api/jobs/${enc(jobId)}/cancel`)
88
+ },
89
+ specs: {
90
+ list: () => get("/api/specs").then((r) => r.specs),
91
+ get: (name) => get(`/api/specs/${enc(name)}`),
92
+ put: (name, body) => put(`/api/specs/${enc(name)}`, body),
93
+ remove: (name) => del(`/api/specs/${enc(name)}`)
94
+ },
95
+ notifiers: {
96
+ list: () => get("/api/notifiers").then((r) => r.notifiers),
97
+ create: (body) => post("/api/notifiers", body),
98
+ setEnabled: (id, enabled) => request("PATCH", `/api/notifiers/${id}`, { enabled }),
99
+ remove: (id) => del(`/api/notifiers/${id}`),
100
+ test: (id) => post(`/api/notifiers/${id}/test`),
101
+ deliveries: (id) => get(`/api/notifiers/${id}/deliveries`),
102
+ redeliver: (id, deliveryId) => post(`/api/notifiers/${id}/deliveries/${deliveryId}/redeliver`),
103
+ meta: () => get("/api/notifiers/meta")
104
+ },
105
+ hostVars: {
106
+ list: () => get("/api/host-vars").then((r) => r.entries),
107
+ put: (name, value, secret = false) => put(`/api/host-vars/${enc(name)}`, { value, secret }),
108
+ remove: (name) => del(`/api/host-vars/${enc(name)}`)
109
+ },
110
+ async waitForJob(jobId, o = {}) {
111
+ const interval = o.intervalMs ?? 2000;
112
+ const deadline = Date.now() + (o.timeoutMs ?? 30 * 60000);
113
+ for (;; ) {
114
+ const job = await client.jobs.get(jobId);
115
+ if (job.state !== "running")
116
+ return job;
117
+ if (Date.now() > deadline) {
118
+ throw new PstackError(0, jobId, { error: `job ${jobId} still running after the wait timeout` });
119
+ }
120
+ await new Promise((r) => setTimeout(r, interval));
121
+ }
122
+ },
123
+ async waitForReady(id, o = {}) {
124
+ const deadline = Date.now() + (o.timeoutMs ?? 10 * 60000);
125
+ for (;; ) {
126
+ const r = await client.deployments.readiness(id, { wait: 30, vars: o.vars });
127
+ if (r.state !== "watching")
128
+ return r;
129
+ if (Date.now() > deadline) {
130
+ throw new PstackError(0, id, { error: `${id} was still converging after the wait timeout` });
131
+ }
132
+ }
133
+ }
134
+ };
135
+ return client;
136
+ }
137
+ async function verifyWebhook(args) {
138
+ const read = (name) => {
139
+ const h = args.headers;
140
+ if (typeof h.get === "function")
141
+ return h.get(name);
142
+ return h[name] ?? h[name.toLowerCase()] ?? null;
143
+ };
144
+ const signature = read("x-pstack-signature");
145
+ const timestamp = read("x-pstack-timestamp");
146
+ const redelivery = read("x-pstack-redelivery") === "1";
147
+ if (!signature || !timestamp)
148
+ return { ok: false, reason: "missing signature headers", redelivery };
149
+ const tolerance = args.toleranceMs ?? 5 * 60000;
150
+ if (tolerance > 0) {
151
+ const age = Math.abs((args.now ?? Date.now()) - Number(timestamp));
152
+ if (!Number.isFinite(age) || age > tolerance)
153
+ return { ok: false, reason: "stale timestamp", redelivery };
154
+ }
155
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(args.secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
156
+ const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${args.rawBody}`));
157
+ const expected = `sha256=${[...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
158
+ if (signature.length !== expected.length)
159
+ return { ok: false, reason: "signature mismatch", redelivery };
160
+ let diff = 0;
161
+ for (let i = 0;i < expected.length; i++)
162
+ diff |= signature.charCodeAt(i) ^ expected.charCodeAt(i);
163
+ if (diff !== 0)
164
+ return { ok: false, reason: "signature mismatch", redelivery };
165
+ return { ok: true, event: read("x-pstack-event") ?? undefined, redelivery };
166
+ }
167
+ export {
168
+ verifyWebhook,
169
+ createClient,
170
+ PstackError
171
+ };
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The shapes this client returns.
3
+ *
4
+ * Hand-written rather than generated, and deliberately NOT imported from the server package: this
5
+ * package must install without pulling in a control plane (and its Docker-shaped assumptions) as a
6
+ * dependency. The anti-drift measure is not a shared type — it is `test/client.test.ts`, which boots
7
+ * the REAL server and drives this client against it, so a field that changes shape fails a test
8
+ * rather than silently lying in a `.d.ts`.
9
+ *
10
+ * Every type is the JSON as it arrives. Nothing is renamed on the way through, because a client that
11
+ * renames the server's fields makes the API docs wrong for anyone reading both.
12
+ */
13
+ export type Kind = 'isolated' | 'shared';
14
+ export type JobAction = 'up' | 'down' | 'verify';
15
+ /** `cancelled` = a person stopped it; nothing it had done was undone. */
16
+ export type JobState = 'running' | 'ok' | 'failed' | 'leaked' | 'cancelled';
17
+ export type ReadinessState = 'watching' | 'ready' | 'failed' | 'timedout';
18
+ export type Health = {
19
+ ok: boolean;
20
+ /** False only in loopback dev mode, where the server refuses to bind off-localhost. */
21
+ authEnforced: boolean;
22
+ hasUsers?: boolean;
23
+ dataDir: string;
24
+ version: string;
25
+ };
26
+ export type DeploymentRow = {
27
+ id: string;
28
+ kind: Kind;
29
+ specName?: string | null;
30
+ /** Null when the spec could not be resolved — see `unresolved`. */
31
+ stack: string | null;
32
+ /**
33
+ * `null` means the server could not determine it, NEVER "no".
34
+ *
35
+ * An unresolved spec has no stack name to look up, and a docker that did not answer is a different
36
+ * fact from "nothing is running". Treating either as `false` is how a live stack gets reported as
37
+ * torn down, so the tri-state is preserved all the way out to callers.
38
+ */
39
+ busy: boolean | null;
40
+ running: boolean | null;
41
+ /** Why the spec could not be resolved — usually a variable this call did not supply. */
42
+ unresolved?: string;
43
+ createdAt?: number;
44
+ updatedAt?: number;
45
+ };
46
+ export type StepResult = {
47
+ axis: string;
48
+ phase: 'requires' | 'up' | 'down' | 'assert_gone' | 'assert_live' | 'compose';
49
+ ok: boolean;
50
+ code: number;
51
+ message?: string;
52
+ skipped: boolean;
53
+ };
54
+ export type Job = {
55
+ id: string;
56
+ stack: string;
57
+ action: JobAction;
58
+ state: JobState;
59
+ startedAt: number;
60
+ endedAt?: number;
61
+ outcome?: {
62
+ ok: boolean;
63
+ steps: StepResult[];
64
+ outputs: Record<string, string>;
65
+ };
66
+ error?: string;
67
+ log?: Array<{
68
+ seq: number;
69
+ at: number;
70
+ level: string;
71
+ message: string;
72
+ }>;
73
+ cancelledBy?: string;
74
+ };
75
+ export type ContainerReadiness = {
76
+ name: string;
77
+ service: string | null;
78
+ state: string;
79
+ health: string | null;
80
+ /** `false` = it is running and nothing probed it. Not the same as "probed and passing". */
81
+ hasHealthcheck: boolean;
82
+ exitCode: number | null;
83
+ restartCount: number;
84
+ ready: boolean;
85
+ failed: boolean;
86
+ reason?: string;
87
+ };
88
+ export type Readiness = {
89
+ stack: string;
90
+ state: ReadinessState;
91
+ containers: ContainerReadiness[];
92
+ startedAt: number;
93
+ endedAt?: number;
94
+ /** False = docker stopped answering; the container fields are last-known, not current. */
95
+ reachable: boolean;
96
+ timeoutMs: number;
97
+ };
98
+ export type RuntimeContainer = {
99
+ id: string;
100
+ name: string;
101
+ service: string | null;
102
+ image: string;
103
+ state: string;
104
+ health: string | null;
105
+ exitCode: number | null;
106
+ restartCount: number;
107
+ networks: string[];
108
+ ingressIp: string | null;
109
+ ports: Array<{
110
+ containerPort: number;
111
+ protocol: string;
112
+ hostPort?: string;
113
+ }>;
114
+ traefikLabels: Record<string, string>;
115
+ };
116
+ export type RuntimeRoute = {
117
+ router: string;
118
+ container: string;
119
+ rule: string;
120
+ hosts: string[];
121
+ service: string | null;
122
+ port: number | null;
123
+ entrypoints: string | null;
124
+ tls: boolean;
125
+ certresolver: string | null;
126
+ priority: string | null;
127
+ target: string | null;
128
+ };
129
+ export type Runtime = {
130
+ id?: string;
131
+ stack: string;
132
+ containers: RuntimeContainer[];
133
+ routes: RuntimeRoute[];
134
+ findings: Array<{
135
+ level: 'error' | 'warn' | 'info';
136
+ message: string;
137
+ }>;
138
+ challenge: 'http01' | 'dns01' | 'unknown';
139
+ reachable: boolean;
140
+ };
141
+ export type Logs = {
142
+ stack: string;
143
+ tail: number;
144
+ service: string | null;
145
+ timestamps?: boolean;
146
+ since?: string | null;
147
+ until?: string | null;
148
+ lines?: number;
149
+ /** False = compose exited non-zero; `text` still carries whatever it printed. */
150
+ ok: boolean;
151
+ text: string;
152
+ };
153
+ export type SpecMeta = {
154
+ name: string;
155
+ kind: Kind;
156
+ description?: string;
157
+ requiredVars?: string[];
158
+ usedBy?: string[];
159
+ updatedAt?: number;
160
+ };
161
+ export type NotifierRow = {
162
+ id: number;
163
+ type: string;
164
+ name: string;
165
+ /** Secret-marked fields arrive masked; there is no read path for the real value. */
166
+ config: Record<string, unknown>;
167
+ events: string[];
168
+ enabled: boolean;
169
+ lastStatus?: string | null;
170
+ lastAt?: number | null;
171
+ };
172
+ export type DeliveryRow = {
173
+ id: number;
174
+ notifierId: number;
175
+ eventId: string;
176
+ event: string;
177
+ status: string;
178
+ attempts: number;
179
+ responseCode: number | null;
180
+ error: string | null;
181
+ createdAt: number;
182
+ updatedAt: number;
183
+ /** The envelope was stored, so this delivery can be replayed. */
184
+ replayable?: boolean;
185
+ };
186
+ export type HostVar = {
187
+ name: string;
188
+ /** Null for a secret — the server has no route that returns one. */
189
+ value: string | null;
190
+ secret: boolean;
191
+ updatedAt: number;
192
+ };
193
+ /** The four fields a webhook receiver gets. `data` is documented per event in docs/webhook-events.md. */
194
+ export type PstackEvent = {
195
+ id: string;
196
+ event: string;
197
+ at: number;
198
+ data: Record<string, unknown>;
199
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@samyx/preview-stacks-client",
3
+ "version": "0.25.0",
4
+ "description": "Typed API client for a pstack control plane: deployments, jobs, readiness, containers, logs, notifiers.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/samishal1998/preview-stacks.git",
8
+ "directory": "packages/client"
9
+ },
10
+ "homepage": "https://github.com/samishal1998/preview-stacks/tree/main/packages/client#readme",
11
+ "license": "MIT",
12
+ "author": "sami.mishal",
13
+ "type": "module",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ },
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "build": "bun build src/index.ts --outdir dist --target node --format esm && tsc -p tsconfig.build.json",
28
+ "test": "bun test",
29
+ "typecheck": "tsc --noEmit",
30
+ "check": "bun test && tsc --noEmit && bun run build",
31
+ "prepublishOnly": "bun run build"
32
+ },
33
+ "keywords": [
34
+ "pstack",
35
+ "preview-environments",
36
+ "docker-compose",
37
+ "api-client",
38
+ "sdk"
39
+ ],
40
+ "main": "./dist/index.js",
41
+ "types": "./dist/index.d.ts"
42
+ }