@samyx/preview-stacks-client 0.25.3 → 0.27.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 CHANGED
@@ -69,6 +69,62 @@ for (const c of rt.containers.filter((c) => c.restartCount > 2)) {
69
69
  const logs = await pstack.deployments.logs('pr-7', { service: 'web', tail: 500, timestamps: true, vars: { PR: 7 } });
70
70
  ```
71
71
 
72
+ ### Sleep and wake
73
+
74
+ A stack that is only looked at now and then can be put to sleep — its compose project goes down, its
75
+ volumes and axes stay — and comes back on the next request to any of its hostnames, or on `wake`.
76
+ (The spec can do this on a timer with a `sleep:` block; see the pstack docs.)
77
+
78
+ ```ts
79
+ const job = await pstack.deployments.sleep('pr-7', { PR: 7 });
80
+ await pstack.waitForJob(job.id);
81
+ const d = await pstack.deployments.get('pr-7', { PR: 7 });
82
+ d.asleep; // { since, reason, hosts, rules } while asleep, null otherwise
83
+ await pstack.deployments.wake('pr-7', { PR: 7 }); // the same as `up`, recorded as a wake
84
+ ```
85
+
86
+ ### Share a read-only link
87
+
88
+ Hand someone the logs of one deployment without creating an account for them. The link carries a
89
+ signed token that reaches exactly the views you name, on that deployment, until it expires (7 days
90
+ by default, 30 at most). The token is returned once and stored nowhere; rotating the server's
91
+ `PSTACK_TOKEN` is the only revocation.
92
+
93
+ ```ts
94
+ const link = await pstack.deployments.share('pr-7', { views: ['logs'], ttl: '24h' });
95
+ console.log(link.url); // https://control.preview.example.com/deployments/pr-7/public-logs-view?token=…
96
+
97
+ // The token also works as this client's bearer, for the reads the link allows:
98
+ const viewer = createClient({ baseUrl, token: link.token });
99
+ await viewer.deployments.logs('pr-7'); // ok
100
+ await viewer.deployments.up('pr-7'); // PstackError 403
101
+ ```
102
+
103
+ ### The swarm
104
+
105
+ On a host that runs previews as swarm stacks, the nodes and the material a new worker needs:
106
+
107
+ ```ts
108
+ const swarm = await pstack.swarm.info(); // { reachable, active, managerAddr, nodes, ports, … }
109
+ const script = await pstack.swarm.join({ format: 'script' }); // a SECRET: treat like a password
110
+ // formats: 'token' | 'command' | 'script' | 'cloud-config' (+ distro for cloud-config)
111
+ ```
112
+
113
+ ### Single sign-on
114
+
115
+ The sign-in itself is two browser redirects (`/api/auth/sso/start` → the provider →
116
+ `/api/auth/sso/callback`); what an SDK can do is configure it.
117
+
118
+ ```ts
119
+ const { callbackUrl, presets } = await pstack.sso.config(); // register callbackUrl with your provider
120
+ await pstack.sso.save({ mode: 'oidc', issuer: 'https://accounts.google.com',
121
+ clientId: '…', clientSecret: '…', label: 'Google' });
122
+ // The issuer is fetched while saving, so a typo throws here rather than failing someone's login.
123
+ ```
124
+
125
+ `config()` returns the client secret as a mask — there is no route that returns the real one, and
126
+ submitting the mask back keeps what is stored.
127
+
72
128
  ### Verify a webhook
73
129
 
74
130
  The half that lives in your receiver. It prevents the two mistakes that make a correct signature look
package/dist/index.d.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  * polling a job without a terminal-state list, or treating `readiness.state === 'watching'` as an
26
26
  * error rather than "ask again".
27
27
  */
28
- import type { DeliveryRow, DeploymentRow, Health, HostVar, Job, Kind, Logs, NotifierRow, Readiness, Runtime, SpecMeta } from './types.ts';
28
+ import type { DeliveryRow, DeploymentRow, Health, HostVar, Job, Kind, Logs, NotifierRow, Readiness, Runtime, ShareLink, ShareView, SpecMeta, SsoConfig, SsoConfigResponse, SwarmInfo } from './types.ts';
29
29
  export * from './types.ts';
30
30
  /** A non-2xx answer. `body` is the parsed JSON, which is where the server's own message lives. */
31
31
  export declare class PstackError extends Error {
@@ -38,7 +38,9 @@ export type ClientOptions = {
38
38
  /** e.g. `https://api.preview.example.com`. A trailing slash is fine. */
39
39
  baseUrl: string;
40
40
  /**
41
- * `PSTACK_TOKEN` (the machine credential CI holds) or a personal token (`pstack_pat_…`).
41
+ * `PSTACK_TOKEN` (the machine credential CI holds), a personal token (`pstack_pat_…`), or the
42
+ * token of a share link (`deployments.share`) — which then reaches exactly the reads that link
43
+ * allows, on that one deployment.
42
44
  *
43
45
  * Optional only because a server with no token configured is loopback-only dev mode. Anywhere
44
46
  * else, every route needs it.
@@ -91,6 +93,22 @@ export declare function createClient(opts: ClientOptions): {
91
93
  verify?: boolean;
92
94
  force?: boolean;
93
95
  }, vars?: Vars) => Promise<Job>;
96
+ /**
97
+ * Take the compose project down and KEEP its volumes and axes. A request to any of its
98
+ * hostnames brings it back (wake-on-call), as does `wake`. Refused (409) for `kind: shared`.
99
+ */
100
+ sleep: (id: string, vars?: Vars) => Promise<Job>;
101
+ /** `up`, recorded as a wake. */
102
+ wake: (id: string, vars?: Vars) => Promise<Job>;
103
+ /**
104
+ * Mint a read-only link to this deployment: `views` default to both `details` and `logs`,
105
+ * `ttl` to 7 days (30 at most). The token is returned once and stored nowhere; rotating
106
+ * PSTACK_TOKEN is the only revocation.
107
+ */
108
+ share: (id: string, body?: {
109
+ views?: ShareView[];
110
+ ttl?: string;
111
+ }) => Promise<ShareLink>;
94
112
  runtime: (id: string, vars?: Vars) => Promise<Runtime>;
95
113
  /**
96
114
  * Is it SERVING yet — separate from "did the deploy job succeed", which only means the
@@ -137,6 +155,42 @@ export declare function createClient(opts: ClientOptions): {
137
155
  note: string;
138
156
  }>;
139
157
  };
158
+ /** The swarm this daemon manages. `reachable: false` means docker did not answer — nothing else is known. */
159
+ swarm: {
160
+ info: () => Promise<SwarmInfo>;
161
+ /**
162
+ * What a new worker runs. A SECRET — whoever holds the token can add a node that runs any
163
+ * task. `cloud-config` renders a user-data file; `distro` picks its Docker install steps.
164
+ */
165
+ join: (o?: {
166
+ format: "token" | "command" | "script" | "cloud-config";
167
+ distro?: string;
168
+ }) => Promise<string>;
169
+ };
170
+ /**
171
+ * The identity provider people sign in with. The sign-in flow itself is two browser redirects
172
+ * (`/api/auth/sso/start` → the provider → `/api/auth/sso/callback`) and has nothing for an SDK
173
+ * to call — this is the configuration around it.
174
+ */
175
+ sso: {
176
+ /** `clientSecret` comes back as a mask. There is no route that returns the real one. */
177
+ config: () => Promise<SsoConfigResponse>;
178
+ /**
179
+ * Save. Omit `clientSecret` (or send the mask back) to keep the stored one. For `mode: 'oidc'`
180
+ * the issuer is fetched here, so a bad one is a 400 now rather than a failed login later.
181
+ */
182
+ save: (config: Partial<SsoConfig> & {
183
+ clientSecret?: string;
184
+ }) => Promise<{
185
+ ok: true;
186
+ config: SsoConfig;
187
+ callbackUrl: string;
188
+ }>;
189
+ /** Forget the provider. Nobody is deleted — accounts it created keep working. */
190
+ remove: () => Promise<{
191
+ ok: true;
192
+ }>;
193
+ };
140
194
  jobs: {
141
195
  list: () => Promise<Job[]>;
142
196
  get: (jobId: string) => Promise<Job>;
package/dist/index.js CHANGED
@@ -40,6 +40,8 @@ function createClient(opts) {
40
40
  signal: opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined
41
41
  });
42
42
  const text = await res.text();
43
+ if (res.ok && (res.headers.get("content-type") ?? "").startsWith("text/plain"))
44
+ return text;
43
45
  let parsed = null;
44
46
  try {
45
47
  parsed = text ? JSON.parse(text) : null;
@@ -67,6 +69,9 @@ function createClient(opts) {
67
69
  up: (id, vars) => post(`/api/deployments/${enc(id)}/up${qs(vars)}`).then((r) => r.job),
68
70
  verify: (id, vars) => post(`/api/deployments/${enc(id)}/verify${qs(vars)}`).then((r) => r.job),
69
71
  down: (id, body = {}, vars) => post(`/api/deployments/${enc(id)}/down${qs(vars)}`, body).then((r) => r.job),
72
+ sleep: (id, vars) => post(`/api/deployments/${enc(id)}/sleep${qs(vars)}`).then((r) => r.job),
73
+ wake: (id, vars) => post(`/api/deployments/${enc(id)}/wake${qs(vars)}`).then((r) => r.job),
74
+ share: (id, body = {}) => post(`/api/deployments/${enc(id)}/share`, body),
70
75
  runtime: (id, vars) => get(`/api/deployments/${enc(id)}/runtime${qs(vars)}`),
71
76
  readiness: (id, o = {}) => get(`/api/deployments/${enc(id)}/readiness${qs(o.vars, { wait: o.wait, refresh: o.refresh ? 1 : undefined, timeout: o.timeout })}`),
72
77
  logs: (id, o = {}) => get(`/api/deployments/${enc(id)}/logs${qs(o.vars, {
@@ -81,6 +86,15 @@ function createClient(opts) {
81
86
  stop: (id, name, o = {}) => post(`/api/deployments/${enc(id)}/containers/${enc(name)}/stop${qs(o.vars, { grace: o.grace })}`),
82
87
  restart: (id, name, o = {}) => post(`/api/deployments/${enc(id)}/containers/${enc(name)}/restart${qs(o.vars, { grace: o.grace })}`)
83
88
  },
89
+ swarm: {
90
+ info: () => get("/api/swarm"),
91
+ join: (o = { format: "command" }) => get(`/api/swarm/join${qs(undefined, { format: o.format, distro: o.distro })}`)
92
+ },
93
+ sso: {
94
+ config: () => get("/api/sso/config"),
95
+ save: (config) => put("/api/sso/config", config),
96
+ remove: () => del("/api/sso/config")
97
+ },
84
98
  jobs: {
85
99
  list: () => get("/api/jobs").then((r) => r.jobs),
86
100
  get: (jobId) => get(`/api/jobs/${enc(jobId)}`).then((r) => r.job),
package/dist/types.d.ts CHANGED
@@ -11,7 +11,21 @@
11
11
  * renames the server's fields makes the API docs wrong for anyone reading both.
12
12
  */
13
13
  export type Kind = 'isolated' | 'shared';
14
- export type JobAction = 'up' | 'down' | 'verify';
14
+ /** `sleep` takes the compose project down (volumes and axes stay); `wake` is `up` recorded under its own name. */
15
+ export type JobAction = 'up' | 'down' | 'verify' | 'sleep' | 'wake';
16
+ export type Orchestrator = 'compose' | 'swarm';
17
+ export type ShareView = 'details' | 'logs';
18
+ /**
19
+ * Present while a deployment is asleep: when, why, and the hostnames a request to which wakes it
20
+ * (captured from its Traefik labels the moment before teardown).
21
+ */
22
+ export type SleepRecord = {
23
+ since: number;
24
+ reason: string;
25
+ hosts: string[];
26
+ /** `HostRegexp` patterns (wildcard subdomains), Go syntax. */
27
+ rules: string[];
28
+ };
15
29
  /** `cancelled` = a person stopped it; nothing it had done was undone. */
16
30
  export type JobState = 'running' | 'ok' | 'failed' | 'leaked' | 'cancelled';
17
31
  export type ReadinessState = 'watching' | 'ready' | 'failed' | 'timedout';
@@ -20,6 +34,11 @@ export type Health = {
20
34
  /** False only in loopback dev mode, where the server refuses to bind off-localhost. */
21
35
  authEnforced: boolean;
22
36
  hasUsers?: boolean;
37
+ /** The configured identity provider, or null. Readable BEFORE authenticating — a login page needs it. */
38
+ sso?: {
39
+ enabled: boolean;
40
+ label: string;
41
+ } | null;
23
42
  dataDir: string;
24
43
  version: string;
25
44
  };
@@ -38,11 +57,57 @@ export type DeploymentRow = {
38
57
  */
39
58
  busy: boolean | null;
40
59
  running: boolean | null;
60
+ /** The sleep record, or null when awake. A sleeping stack is neither running nor torn down. */
61
+ asleep?: SleepRecord | null;
62
+ /** `compose` or `swarm`; null when the spec has no compose section or could not be resolved. */
63
+ orchestrator?: Orchestrator | null;
64
+ /** The spec's `sleep:` policy as durations (`2h`), on the detail route only. */
65
+ sleep?: {
66
+ idle: string | null;
67
+ after: string | null;
68
+ } | null;
41
69
  /** Why the spec could not be resolved — usually a variable this call did not supply. */
42
70
  unresolved?: string;
43
71
  createdAt?: number;
44
72
  updatedAt?: number;
45
73
  };
74
+ export type SwarmNode = {
75
+ id: string;
76
+ hostname: string;
77
+ role: 'manager' | 'worker';
78
+ status: string;
79
+ availability: string;
80
+ managerStatus: string | null;
81
+ engineVersion: string;
82
+ /** The node this API runs on. */
83
+ self: boolean;
84
+ };
85
+ export type SwarmInfo = {
86
+ /** False when docker did not answer — every other field is then unknown, not empty. */
87
+ reachable: boolean;
88
+ /** Whether this daemon is a swarm manager. Inactive hosts run previews with compose. */
89
+ active: boolean;
90
+ nodeId: string | null;
91
+ /** `<advertise-addr>:2377`, what a worker joins. */
92
+ managerAddr: string | null;
93
+ nodes: SwarmNode[];
94
+ error?: string;
95
+ /** The ports a worker must reach on the manager (and nodes on each other). */
96
+ ports: Array<{
97
+ port: string;
98
+ why: string;
99
+ }>;
100
+ note: string;
101
+ };
102
+ export type ShareLink = {
103
+ /** `https://control.<domain>/deployments/<id>/public-logs-view?token=…` */
104
+ url: string;
105
+ /** The JWT. Returned ONCE; stored nowhere on the server. Works as this client's `token` for the GETs it allows. */
106
+ token: string;
107
+ views: ShareView[];
108
+ /** Epoch ms. */
109
+ expiresAt: number;
110
+ };
46
111
  export type StepResult = {
47
112
  axis: string;
48
113
  phase: 'requires' | 'up' | 'down' | 'assert_gone' | 'assert_live' | 'compose';
@@ -112,6 +177,12 @@ export type RuntimeContainer = {
112
177
  hostPort?: string;
113
178
  }>;
114
179
  traefikLabels: Record<string, string>;
180
+ /** Epoch ms when the process started; null when docker did not say. */
181
+ startedAt?: number | null;
182
+ /** The swarm node it runs on, or null under compose. */
183
+ node?: string | null;
184
+ /** A swarm task on ANOTHER node: listed, but out of reach of exec/stop from the manager. */
185
+ remote?: boolean;
115
186
  };
116
187
  export type RuntimeRoute = {
117
188
  router: string;
@@ -128,6 +199,8 @@ export type RuntimeRoute = {
128
199
  };
129
200
  export type Runtime = {
130
201
  id?: string;
202
+ orchestrator?: Orchestrator | null;
203
+ asleep?: SleepRecord | null;
131
204
  stack: string;
132
205
  containers: RuntimeContainer[];
133
206
  routes: RuntimeRoute[];
@@ -197,3 +270,51 @@ export type PstackEvent = {
197
270
  at: number;
198
271
  data: Record<string, unknown>;
199
272
  };
273
+ export type SsoClaimMap = {
274
+ subject: string;
275
+ username: string;
276
+ email: string;
277
+ name: string;
278
+ avatar: string;
279
+ };
280
+ /** The stored provider. The client secret is NEVER part of this — it has no read path. */
281
+ export type SsoConfig = {
282
+ mode: 'oidc' | 'oauth2';
283
+ enabled: boolean;
284
+ /** Button text, and the `providerKey` half of an account's identity. */
285
+ label: string;
286
+ clientId: string;
287
+ /** Mode A: the issuer, or a full `.well-known` URL. */
288
+ discoveryUrl: string;
289
+ /** Mode B: a preset key, or `custom`. */
290
+ provider: string;
291
+ authorizeUrl: string;
292
+ tokenUrl: string;
293
+ userInfoUrl: string;
294
+ /** Consulted only when the profile carries no address (GitHub's default). */
295
+ emailsUrl: string;
296
+ scopes: string;
297
+ claimMap: SsoClaimMap;
298
+ /** Non-empty ⇒ a login outside these domains is refused, INCLUDING one with no address at all. */
299
+ allowedEmailDomains: string[];
300
+ defaultRole: string;
301
+ };
302
+ export type SsoPreset = {
303
+ key: string;
304
+ label: string;
305
+ authorizeUrl: string;
306
+ tokenUrl: string;
307
+ userInfoUrl: string | null;
308
+ scopes: string;
309
+ claimMap: SsoClaimMap;
310
+ };
311
+ export type SsoConfigResponse = {
312
+ configured: boolean;
313
+ /** Register THIS with the provider, byte for byte. Built server-side; never guess it. */
314
+ callbackUrl: string;
315
+ presets: SsoPreset[];
316
+ config: SsoConfig | null;
317
+ /** A mask when one is stored. Submit it back unchanged to keep the stored secret. */
318
+ clientSecret: string;
319
+ updatedAt: number | null;
320
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samyx/preview-stacks-client",
3
- "version": "0.25.3",
3
+ "version": "0.27.0",
4
4
  "description": "Typed API client for a pstack control plane: deployments, jobs, readiness, containers, logs, notifiers.",
5
5
  "repository": {
6
6
  "type": "git",