ras-stack 0.18.0 → 0.19.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
@@ -396,6 +396,10 @@ e2e:
396
396
 
397
397
  The loaded image tag is also available to the command as `RAS_STACK_TEST_IMAGE`. Build and browser durations are written to the job summary, and failure artifacts remain configurable. Applications that need extra caches, services, registry publication, or a different PR/main topology can use `actions/build-container` and `actions/setup-playwright` inside their own job instead.
398
398
 
399
+ Dokploy previews can share the application/domain/image/environment/deploy/health/delete/prune lifecycle through `ras-stack/preview/dokploy`. Product-specific Stripe, storage, seed, and verification work stays around the manager's configure and cleanup hooks.
400
+
401
+ The reusable `build-preview-image.yml` workflow publishes same-repository pull requests directly but turns fork builds into one-day artifacts without exposing a token or secret. A trusted `workflow_run` job can publish that artifact with `actions/publish-preview-image` before running its repository-owned deployment command. The event wrapper and secret-to-environment mapping remain in each application so the trust boundary is visible locally.
402
+
399
403
  The workflow consumes pending changesets, commits the resulting versions and changelogs, pushes the commit and tag atomically, and creates a GitHub Release. It does nothing when no versioned changeset is present. The caller owns its checks, Changesets configuration, release policy, and any deployment that follows the release.
400
404
 
401
405
  Pin actions and reusable workflows to a release tag and let Dependabot propose upgrades.
@@ -0,0 +1,71 @@
1
+ export type DokployApplication = {
2
+ applicationId: string;
3
+ name: string;
4
+ };
5
+ export type DokployClientOptions = {
6
+ url: string;
7
+ apiKey: string;
8
+ environmentId: string;
9
+ fetch?: typeof fetch;
10
+ log?: (message: string) => void;
11
+ };
12
+ export declare class DokployClient {
13
+ private readonly options;
14
+ private readonly request;
15
+ private readonly log;
16
+ constructor(options: DokployClientOptions);
17
+ get environmentId(): string;
18
+ api<T = unknown>(procedure: string, options?: {
19
+ query?: Record<string, string>;
20
+ body?: unknown;
21
+ }): Promise<T>;
22
+ applications(): Promise<DokployApplication[]>;
23
+ application(name: string): Promise<DokployApplication | undefined>;
24
+ }
25
+ export type DokployPreviewOptions = {
26
+ client: DokployClient;
27
+ applicationName: (prNumber: string) => string;
28
+ hostname: (prNumber: string) => string;
29
+ port: number;
30
+ healthPath?: string;
31
+ deploymentTimeoutMs?: number;
32
+ healthTimeoutMs?: number;
33
+ pollIntervalMs?: number;
34
+ fetch?: typeof fetch;
35
+ sleep?: (milliseconds: number) => Promise<void>;
36
+ now?: () => number;
37
+ log?: (message: string) => void;
38
+ };
39
+ export type DeployPreviewOptions = {
40
+ prNumber: string;
41
+ image: string;
42
+ environment: string;
43
+ registry?: {
44
+ username: string;
45
+ password: string;
46
+ };
47
+ configure?: (context: {
48
+ applicationId: string;
49
+ client: DokployClient;
50
+ host: string;
51
+ }) => void | Promise<void>;
52
+ };
53
+ export declare class DokployPreviewManager {
54
+ private readonly options;
55
+ private readonly request;
56
+ private readonly pause;
57
+ private readonly log;
58
+ private readonly now;
59
+ constructor(options: DokployPreviewOptions);
60
+ deploy(options: DeployPreviewOptions): Promise<{
61
+ applicationId: string;
62
+ host: string;
63
+ url: string;
64
+ }>;
65
+ delete(prNumber: string, beforeDelete?: (application: DokployApplication | undefined) => void | Promise<void>): Promise<boolean>;
66
+ prune(openPullRequests: ReadonlySet<string>, beforeDelete?: (prNumber: string, application: DokployApplication) => void | Promise<void>): Promise<string[]>;
67
+ private waitForDeployment;
68
+ private waitForHealth;
69
+ }
70
+ export declare function pullRequestNumber(value: string): string;
71
+ export declare function previewHostname(value: string): string;
@@ -0,0 +1,192 @@
1
+ export class DokployClient {
2
+ options;
3
+ request;
4
+ log;
5
+ constructor(options) {
6
+ this.options = options;
7
+ this.request = options.fetch ?? fetch;
8
+ this.log = options.log ?? console.log;
9
+ }
10
+ get environmentId() {
11
+ return this.options.environmentId;
12
+ }
13
+ async api(procedure, options = {}) {
14
+ const url = new URL(`${this.options.url.replace(/\/$/, '')}/api/${procedure}`);
15
+ for (const [key, value] of Object.entries(options.query ?? {}))
16
+ url.searchParams.set(key, value);
17
+ this.log(`→ ${procedure}`);
18
+ const response = await this.request(url, {
19
+ method: options.body === undefined ? 'GET' : 'POST',
20
+ headers: {
21
+ 'x-api-key': this.options.apiKey,
22
+ ...(options.body === undefined ? {} : { 'content-type': 'application/json' }),
23
+ },
24
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
25
+ });
26
+ const text = await response.text();
27
+ if (!response.ok)
28
+ throw new Error(`${procedure} failed with ${response.status}: ${text.slice(0, 500)}`);
29
+ if (!text)
30
+ return undefined;
31
+ try {
32
+ return JSON.parse(text);
33
+ }
34
+ catch {
35
+ throw new Error(`${procedure} returned ${response.status} with a non-JSON body: ${text.slice(0, 200)}`);
36
+ }
37
+ }
38
+ async applications() {
39
+ const environment = await this.api('environment.one', {
40
+ query: { environmentId: this.options.environmentId },
41
+ });
42
+ if (!environment)
43
+ throw new Error('environment.one returned an empty response');
44
+ return environment.applications ?? [];
45
+ }
46
+ async application(name) {
47
+ return (await this.applications()).find((application) => application.name === name);
48
+ }
49
+ }
50
+ export class DokployPreviewManager {
51
+ options;
52
+ request;
53
+ pause;
54
+ log;
55
+ now;
56
+ constructor(options) {
57
+ this.options = options;
58
+ this.request = options.fetch ?? fetch;
59
+ this.pause = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
60
+ this.log = options.log ?? console.log;
61
+ this.now = options.now ?? Date.now;
62
+ }
63
+ async deploy(options) {
64
+ const prNumber = pullRequestNumber(options.prNumber);
65
+ const name = this.options.applicationName(prNumber);
66
+ const host = previewHostname(this.options.hostname(prNumber));
67
+ let application = await this.options.client.application(name);
68
+ if (!application) {
69
+ await this.options.client.api('application.create', {
70
+ body: { name, appName: name, environmentId: this.options.client.environmentId },
71
+ });
72
+ application = await this.options.client.application(name);
73
+ if (!application)
74
+ throw new Error(`Dokploy did not report ${name} after creating it`);
75
+ }
76
+ const applicationId = application.applicationId;
77
+ const details = await this.options.client.api('application.one', {
78
+ query: { applicationId },
79
+ });
80
+ if (!details?.domains?.some((domain) => domain.host === host)) {
81
+ await this.options.client.api('domain.create', {
82
+ body: {
83
+ applicationId,
84
+ host,
85
+ path: '/',
86
+ port: this.options.port,
87
+ https: true,
88
+ certificateType: 'letsencrypt',
89
+ domainType: 'application',
90
+ },
91
+ });
92
+ }
93
+ await options.configure?.({ applicationId, client: this.options.client, host });
94
+ await this.options.client.api('application.saveDockerProvider', {
95
+ body: {
96
+ applicationId,
97
+ dockerImage: options.image,
98
+ username: options.registry?.username ?? null,
99
+ password: options.registry?.password ?? null,
100
+ registryUrl: options.registry ? options.image.split('/')[0] : null,
101
+ },
102
+ });
103
+ await this.options.client.api('application.saveEnvironment', {
104
+ body: { applicationId, env: options.environment, buildArgs: null, buildSecrets: null, createEnvFile: false },
105
+ });
106
+ await this.options.client.api('application.deploy', { body: { applicationId } });
107
+ await this.waitForDeployment(applicationId);
108
+ const url = `https://${host}`;
109
+ await this.waitForHealth(new URL(this.options.healthPath ?? '/api/health', url).toString());
110
+ this.log(`Preview ready at ${url}`);
111
+ return { applicationId, host, url };
112
+ }
113
+ async delete(prNumber, beforeDelete) {
114
+ const name = this.options.applicationName(pullRequestNumber(prNumber));
115
+ const application = await this.options.client.application(name);
116
+ await beforeDelete?.(application);
117
+ if (!application)
118
+ return false;
119
+ await this.options.client.api('application.delete', { body: { applicationId: application.applicationId } });
120
+ return true;
121
+ }
122
+ async prune(openPullRequests, beforeDelete) {
123
+ const deleted = [];
124
+ for (const application of await this.options.client.applications()) {
125
+ const prNumber = previewApplicationPrNumber(application.name, this.options.applicationName);
126
+ if (!prNumber || openPullRequests.has(prNumber))
127
+ continue;
128
+ // Dokploy mutations are intentionally ordered to avoid overwhelming one environment.
129
+ // oxlint-disable-next-line no-await-in-loop
130
+ await beforeDelete?.(prNumber, application);
131
+ // oxlint-disable-next-line no-await-in-loop
132
+ await this.options.client.api('application.delete', { body: { applicationId: application.applicationId } });
133
+ deleted.push(prNumber);
134
+ }
135
+ return deleted;
136
+ }
137
+ async waitForDeployment(applicationId) {
138
+ const deadline = this.now() + (this.options.deploymentTimeoutMs ?? 600_000);
139
+ while (this.now() < deadline) {
140
+ // Polling is intentionally sequential; each response determines whether another request is needed.
141
+ // oxlint-disable-next-line no-await-in-loop
142
+ await this.pause(this.options.pollIntervalMs ?? 5_000);
143
+ // oxlint-disable-next-line no-await-in-loop
144
+ const { applicationStatus } = await this.options.client.api('application.one', {
145
+ query: { applicationId },
146
+ });
147
+ if (applicationStatus === 'done')
148
+ return;
149
+ if (applicationStatus === 'error')
150
+ throw new Error('Dokploy reported a failed deployment');
151
+ }
152
+ throw new Error('Timed out waiting for the Dokploy deployment to finish');
153
+ }
154
+ async waitForHealth(url) {
155
+ const deadline = this.now() + (this.options.healthTimeoutMs ?? 300_000);
156
+ let lastFailure = 'no response';
157
+ while (this.now() < deadline) {
158
+ try {
159
+ // Polling is intentionally sequential; each response determines whether another request is needed.
160
+ // oxlint-disable-next-line no-await-in-loop
161
+ const response = await this.request(url);
162
+ if (response.status === 200)
163
+ return;
164
+ lastFailure = `status ${response.status}`;
165
+ }
166
+ catch (error) {
167
+ lastFailure = error instanceof Error ? error.message : String(error);
168
+ }
169
+ // oxlint-disable-next-line no-await-in-loop
170
+ await this.pause(this.options.pollIntervalMs ?? 5_000);
171
+ }
172
+ throw new Error(`Timed out waiting for ${url} (${lastFailure})`);
173
+ }
174
+ }
175
+ export function pullRequestNumber(value) {
176
+ if (!/^\d+$/.test(value))
177
+ throw new Error('pull request number must contain only digits');
178
+ return value;
179
+ }
180
+ export function previewHostname(value) {
181
+ const parsed = new URL(`https://${value}`);
182
+ if (parsed.hostname !== value || parsed.port || parsed.username || parsed.password || parsed.pathname !== '/') {
183
+ throw new Error('preview hostname must be a bare hostname');
184
+ }
185
+ return value;
186
+ }
187
+ function previewApplicationPrNumber(name, applicationName) {
188
+ const match = /^(\d+)$/.exec(name.match(/(\d+)$/)?.[1] ?? '');
189
+ const prNumber = match?.[1];
190
+ return prNumber && applicationName(prNumber) === name ? prNumber : undefined;
191
+ }
192
+ //# sourceMappingURL=dokploy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dokploy.js","sourceRoot":"","sources":["../../src/preview/dokploy.ts"],"names":[],"mappings":"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","sourcesContent":["export type DokployApplication = { applicationId: string; name: string }\n\nexport type DokployClientOptions = {\n url: string\n apiKey: string\n environmentId: string\n fetch?: typeof fetch\n log?: (message: string) => void\n}\n\nexport class DokployClient {\n private readonly request: typeof fetch\n private readonly log: (message: string) => void\n\n constructor(private readonly options: DokployClientOptions) {\n this.request = options.fetch ?? fetch\n this.log = options.log ?? console.log\n }\n\n get environmentId() {\n return this.options.environmentId\n }\n\n async api<T = unknown>(procedure: string, options: { query?: Record<string, string>; body?: unknown } = {}): Promise<T> {\n const url = new URL(`${this.options.url.replace(/\\/$/, '')}/api/${procedure}`)\n for (const [key, value] of Object.entries(options.query ?? {})) url.searchParams.set(key, value)\n this.log(`→ ${procedure}`)\n const response = await this.request(url, {\n method: options.body === undefined ? 'GET' : 'POST',\n headers: {\n 'x-api-key': this.options.apiKey,\n ...(options.body === undefined ? {} : { 'content-type': 'application/json' }),\n },\n ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),\n })\n const text = await response.text()\n if (!response.ok) throw new Error(`${procedure} failed with ${response.status}: ${text.slice(0, 500)}`)\n if (!text) return undefined as T\n try {\n return JSON.parse(text) as T\n } catch {\n throw new Error(`${procedure} returned ${response.status} with a non-JSON body: ${text.slice(0, 200)}`)\n }\n }\n\n async applications() {\n const environment = await this.api<{ applications?: DokployApplication[] } | undefined>('environment.one', {\n query: { environmentId: this.options.environmentId },\n })\n if (!environment) throw new Error('environment.one returned an empty response')\n return environment.applications ?? []\n }\n\n async application(name: string) {\n return (await this.applications()).find((application) => application.name === name)\n }\n}\n\nexport type DokployPreviewOptions = {\n client: DokployClient\n applicationName: (prNumber: string) => string\n hostname: (prNumber: string) => string\n port: number\n healthPath?: string\n deploymentTimeoutMs?: number\n healthTimeoutMs?: number\n pollIntervalMs?: number\n fetch?: typeof fetch\n sleep?: (milliseconds: number) => Promise<void>\n now?: () => number\n log?: (message: string) => void\n}\n\nexport type DeployPreviewOptions = {\n prNumber: string\n image: string\n environment: string\n registry?: { username: string; password: string }\n configure?: (context: { applicationId: string; client: DokployClient; host: string }) => void | Promise<void>\n}\n\nexport class DokployPreviewManager {\n private readonly request: typeof fetch\n private readonly pause: (milliseconds: number) => Promise<void>\n private readonly log: (message: string) => void\n private readonly now: () => number\n\n constructor(private readonly options: DokployPreviewOptions) {\n this.request = options.fetch ?? fetch\n this.pause = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)))\n this.log = options.log ?? console.log\n this.now = options.now ?? Date.now\n }\n\n async deploy(options: DeployPreviewOptions) {\n const prNumber = pullRequestNumber(options.prNumber)\n const name = this.options.applicationName(prNumber)\n const host = previewHostname(this.options.hostname(prNumber))\n let application = await this.options.client.application(name)\n if (!application) {\n await this.options.client.api('application.create', {\n body: { name, appName: name, environmentId: this.options.client.environmentId },\n })\n application = await this.options.client.application(name)\n if (!application) throw new Error(`Dokploy did not report ${name} after creating it`)\n }\n const applicationId = application.applicationId\n const details = await this.options.client.api<{ domains?: { host: string }[] } | undefined>('application.one', {\n query: { applicationId },\n })\n if (!details?.domains?.some((domain) => domain.host === host)) {\n await this.options.client.api('domain.create', {\n body: {\n applicationId,\n host,\n path: '/',\n port: this.options.port,\n https: true,\n certificateType: 'letsencrypt',\n domainType: 'application',\n },\n })\n }\n await options.configure?.({ applicationId, client: this.options.client, host })\n await this.options.client.api('application.saveDockerProvider', {\n body: {\n applicationId,\n dockerImage: options.image,\n username: options.registry?.username ?? null,\n password: options.registry?.password ?? null,\n registryUrl: options.registry ? options.image.split('/')[0] : null,\n },\n })\n await this.options.client.api('application.saveEnvironment', {\n body: { applicationId, env: options.environment, buildArgs: null, buildSecrets: null, createEnvFile: false },\n })\n await this.options.client.api('application.deploy', { body: { applicationId } })\n await this.waitForDeployment(applicationId)\n const url = `https://${host}`\n await this.waitForHealth(new URL(this.options.healthPath ?? '/api/health', url).toString())\n this.log(`Preview ready at ${url}`)\n return { applicationId, host, url }\n }\n\n async delete(prNumber: string, beforeDelete?: (application: DokployApplication | undefined) => void | Promise<void>) {\n const name = this.options.applicationName(pullRequestNumber(prNumber))\n const application = await this.options.client.application(name)\n await beforeDelete?.(application)\n if (!application) return false\n await this.options.client.api('application.delete', { body: { applicationId: application.applicationId } })\n return true\n }\n\n async prune(\n openPullRequests: ReadonlySet<string>,\n beforeDelete?: (prNumber: string, application: DokployApplication) => void | Promise<void>,\n ) {\n const deleted: string[] = []\n for (const application of await this.options.client.applications()) {\n const prNumber = previewApplicationPrNumber(application.name, this.options.applicationName)\n if (!prNumber || openPullRequests.has(prNumber)) continue\n // Dokploy mutations are intentionally ordered to avoid overwhelming one environment.\n // oxlint-disable-next-line no-await-in-loop\n await beforeDelete?.(prNumber, application)\n // oxlint-disable-next-line no-await-in-loop\n await this.options.client.api('application.delete', { body: { applicationId: application.applicationId } })\n deleted.push(prNumber)\n }\n return deleted\n }\n\n private async waitForDeployment(applicationId: string) {\n const deadline = this.now() + (this.options.deploymentTimeoutMs ?? 600_000)\n while (this.now() < deadline) {\n // Polling is intentionally sequential; each response determines whether another request is needed.\n // oxlint-disable-next-line no-await-in-loop\n await this.pause(this.options.pollIntervalMs ?? 5_000)\n // oxlint-disable-next-line no-await-in-loop\n const { applicationStatus } = await this.options.client.api<{ applicationStatus: string }>('application.one', {\n query: { applicationId },\n })\n if (applicationStatus === 'done') return\n if (applicationStatus === 'error') throw new Error('Dokploy reported a failed deployment')\n }\n throw new Error('Timed out waiting for the Dokploy deployment to finish')\n }\n\n private async waitForHealth(url: string) {\n const deadline = this.now() + (this.options.healthTimeoutMs ?? 300_000)\n let lastFailure = 'no response'\n while (this.now() < deadline) {\n try {\n // Polling is intentionally sequential; each response determines whether another request is needed.\n // oxlint-disable-next-line no-await-in-loop\n const response = await this.request(url)\n if (response.status === 200) return\n lastFailure = `status ${response.status}`\n } catch (error) {\n lastFailure = error instanceof Error ? error.message : String(error)\n }\n // oxlint-disable-next-line no-await-in-loop\n await this.pause(this.options.pollIntervalMs ?? 5_000)\n }\n throw new Error(`Timed out waiting for ${url} (${lastFailure})`)\n }\n}\n\nexport function pullRequestNumber(value: string) {\n if (!/^\\d+$/.test(value)) throw new Error('pull request number must contain only digits')\n return value\n}\n\nexport function previewHostname(value: string) {\n const parsed = new URL(`https://${value}`)\n if (parsed.hostname !== value || parsed.port || parsed.username || parsed.password || parsed.pathname !== '/') {\n throw new Error('preview hostname must be a bare hostname')\n }\n return value\n}\n\nfunction previewApplicationPrNumber(name: string, applicationName: (prNumber: string) => string) {\n const match = /^(\\d+)$/.exec(name.match(/(\\d+)$/)?.[1] ?? '')\n const prNumber = match?.[1]\n return prNumber && applicationName(prNumber) === name ? prNumber : undefined\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ras-stack",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Composable full-stack primitives shared across Richard Solomou's applications.",
5
5
  "keywords": [
6
6
  "authentication",
@@ -63,6 +63,10 @@
63
63
  "types": "./dist/policy/index.d.ts",
64
64
  "default": "./dist/policy/index.js"
65
65
  },
66
+ "./preview/dokploy": {
67
+ "types": "./dist/preview/dokploy.d.ts",
68
+ "default": "./dist/preview/dokploy.js"
69
+ },
66
70
  "./realtime": {
67
71
  "types": "./dist/realtime/index.d.ts",
68
72
  "default": "./dist/realtime/index.js"