@permute/sdk 0.3.0 → 0.5.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 +284 -11
- package/dist/app.d.ts +23 -0
- package/dist/app.js +65 -0
- package/dist/client.d.ts +40 -16
- package/dist/client.js +81 -14
- package/dist/deploy.d.ts +20 -0
- package/dist/deploy.js +143 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +14 -0
- package/dist/generated/openapi.d.ts +597 -5
- package/dist/generated/runtime-contract.d.ts +3 -1
- package/dist/generated/runtime-contract.js +6614 -3753
- package/dist/types.d.ts +36 -0
- package/package.json +13 -3
package/dist/deploy.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PermuteClient } from './client.js';
|
|
2
|
+
import type { AppSiteSaveRequest, PermuteRequestOptions } from './types.js';
|
|
3
|
+
export interface DeployAppOptions {
|
|
4
|
+
appId: string;
|
|
5
|
+
directory: string;
|
|
6
|
+
/** Defaults to true. */
|
|
7
|
+
spa?: boolean;
|
|
8
|
+
/** Explicitly approve replacing this exact draft. Omit to require a clean published app. */
|
|
9
|
+
expectedDraft?: AppSiteSaveRequest['expectedDraft'];
|
|
10
|
+
/** Omit to preserve bindings; [] removes them. Revisions must already be published. */
|
|
11
|
+
actions?: AppSiteSaveRequest['actions'];
|
|
12
|
+
/** Omit to preserve source bindings; [] removes them. */
|
|
13
|
+
sourceIds?: AppSiteSaveRequest['sourceIds'];
|
|
14
|
+
}
|
|
15
|
+
/** Upload, save, and publish an existing live app. Builds and provisions nothing; never retries mutations. */
|
|
16
|
+
export declare function deployApp(client: PermuteClient, options: DeployAppOptions, requestOptions?: PermuteRequestOptions): Promise<{
|
|
17
|
+
url: string;
|
|
18
|
+
revisionId: string;
|
|
19
|
+
changed: boolean;
|
|
20
|
+
}>;
|
package/dist/deploy.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { lstat, readdir, open } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
6
|
+
import { Ajv } from 'ajv';
|
|
7
|
+
import { PermuteApiError } from './errors.js';
|
|
8
|
+
import { publicApiRoutes } from './generated/runtime-contract.js';
|
|
9
|
+
const ajv = new Ajv({ strict: false, allErrors: true, validateFormats: false });
|
|
10
|
+
const validateUploads = ajv.compile(publicApiRoutes.appsUploadsCreate.bodySchema);
|
|
11
|
+
const validateSite = ajv.compile(publicApiRoutes.appsSiteUpdate.bodySchema);
|
|
12
|
+
function conflict(message) {
|
|
13
|
+
throw new PermuteApiError({ status: 409, code: 'deployment_conflict', message });
|
|
14
|
+
}
|
|
15
|
+
/** Node/Bun only. Validate the complete build before making any upload or draft mutation. */
|
|
16
|
+
async function readBuild(directory, body, spa, signal) {
|
|
17
|
+
const entries = [];
|
|
18
|
+
const walk = async (absolutePath, path) => {
|
|
19
|
+
signal?.throwIfAborted();
|
|
20
|
+
const stat = await lstat(absolutePath);
|
|
21
|
+
if (stat.isSymbolicLink())
|
|
22
|
+
throw new Error(`Build contains a symbolic link: ${path || directory}`);
|
|
23
|
+
if (stat.isDirectory()) {
|
|
24
|
+
for (const name of (await readdir(absolutePath)).sort()) {
|
|
25
|
+
await walk(join(absolutePath, name), path ? `${path}/${name}` : name);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else if (stat.isFile() && path) {
|
|
29
|
+
entries.push({ path, absolutePath, size: stat.size, sha256: '0'.repeat(64) });
|
|
30
|
+
if (!validateUploads({ files: entries.map(({ absolutePath: _, ...file }) => file) })) {
|
|
31
|
+
throw new PermuteApiError({
|
|
32
|
+
status: 0,
|
|
33
|
+
code: 'invalid_build',
|
|
34
|
+
message: 'Build files exceed app limits or contain an unsupported path.',
|
|
35
|
+
details: validateUploads.errors,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else
|
|
40
|
+
throw new Error(`Unsupported build entry: ${path || directory}`);
|
|
41
|
+
};
|
|
42
|
+
await walk(directory, '');
|
|
43
|
+
const manifest = entries.map(({ absolutePath: _, ...file }) => file);
|
|
44
|
+
// JSON Schema cannot express the server's manifest refinements. Check these before uploading.
|
|
45
|
+
if (!manifest.some((file) => file.path === 'index.html') ||
|
|
46
|
+
manifest.reduce((total, file) => total + file.size, 0) > 50 * 1024 * 1024 ||
|
|
47
|
+
new Set(body.actions?.map((action) => action.actionId)).size !== (body.actions?.length ?? 0) ||
|
|
48
|
+
new Set(body.sourceIds).size !== (body.sourceIds?.length ?? 0) ||
|
|
49
|
+
!validateSite({ ...body, site: { files: manifest, spa } })) {
|
|
50
|
+
throw new PermuteApiError({
|
|
51
|
+
status: 0,
|
|
52
|
+
code: 'invalid_build',
|
|
53
|
+
message: 'Invalid app build or bindings. Include index.html, stay within 50 MiB, and use valid app settings.',
|
|
54
|
+
details: validateSite.errors,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const files = [];
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
signal?.throwIfAborted();
|
|
60
|
+
const file = await open(entry.absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
61
|
+
try {
|
|
62
|
+
const stat = await file.stat();
|
|
63
|
+
if (!stat.isFile() || stat.size !== entry.size)
|
|
64
|
+
throw new Error(`Build changed: ${entry.path}`);
|
|
65
|
+
const data = Buffer.alloc(entry.size + 1);
|
|
66
|
+
let length = 0;
|
|
67
|
+
while (length < data.byteLength) {
|
|
68
|
+
signal?.throwIfAborted();
|
|
69
|
+
const { bytesRead } = await file.read(data, length, data.byteLength - length, null);
|
|
70
|
+
if (!bytesRead)
|
|
71
|
+
break;
|
|
72
|
+
length += bytesRead;
|
|
73
|
+
}
|
|
74
|
+
if (length !== entry.size)
|
|
75
|
+
throw new Error(`Build changed: ${entry.path}`);
|
|
76
|
+
files.push({ path: entry.path, data: data.subarray(0, length) });
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await file.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return files;
|
|
83
|
+
}
|
|
84
|
+
/** Upload, save, and publish an existing live app. Builds and provisions nothing; never retries mutations. */
|
|
85
|
+
export async function deployApp(client, options, requestOptions) {
|
|
86
|
+
const { appId, directory, spa = true, ...bindings } = structuredClone(options);
|
|
87
|
+
const app = await client.apps.get(appId, requestOptions);
|
|
88
|
+
if (app.deployment?.status !== 'live')
|
|
89
|
+
throw new Error('App hosting must be live before deploying. Inspect client.apps.get(appId).');
|
|
90
|
+
const history = await client.revisions.list(appId, requestOptions);
|
|
91
|
+
const expected = bindings.expectedDraft ?? app.draft;
|
|
92
|
+
if (history.draft?.revisionId !== expected.revisionId ||
|
|
93
|
+
history.draft.sequence !== expected.sequence ||
|
|
94
|
+
app.draft.revisionId !== expected.revisionId ||
|
|
95
|
+
app.draft.sequence !== expected.sequence) {
|
|
96
|
+
conflict('App draft changed. Read and review it before deploying.');
|
|
97
|
+
}
|
|
98
|
+
if (!bindings.expectedDraft && history.draft.revisionId !== history.published?.revisionId) {
|
|
99
|
+
conflict('App has an unpublished draft. Review it and supply its expectedDraft to replace it.');
|
|
100
|
+
}
|
|
101
|
+
const body = { ...bindings, expectedDraft: expected };
|
|
102
|
+
const files = await readBuild(directory, body, spa, requestOptions?.signal);
|
|
103
|
+
// Only a clean published draft can be skipped: an approved draft may also contain settings edits.
|
|
104
|
+
if (history.published && history.draft.revisionId === history.published.revisionId) {
|
|
105
|
+
const revision = await client.revisions.get(appId, history.published.revisionId, requestOptions);
|
|
106
|
+
const previous = {
|
|
107
|
+
expectedDraft: expected,
|
|
108
|
+
site: revision.value.site,
|
|
109
|
+
actions: revision.value.actions,
|
|
110
|
+
sourceIds: revision.value.sourceIds,
|
|
111
|
+
};
|
|
112
|
+
const localManifest = files.map(({ path, data }) => ({
|
|
113
|
+
path,
|
|
114
|
+
size: data.length,
|
|
115
|
+
sha256: createHash('sha256').update(data).digest('hex'),
|
|
116
|
+
}));
|
|
117
|
+
if (validateSite(previous) &&
|
|
118
|
+
previous.site.spa === spa &&
|
|
119
|
+
isDeepStrictEqual(new Map(previous.site.files.map((file) => [file.path, file])), new Map(localManifest.map((file) => [file.path, file]))) &&
|
|
120
|
+
(bindings.actions === undefined ||
|
|
121
|
+
isDeepStrictEqual(new Map((previous.actions ?? []).map((action) => [action.actionId, action.revisionId])), new Map(bindings.actions.map((action) => [action.actionId, action.revisionId])))) &&
|
|
122
|
+
(bindings.sourceIds === undefined ||
|
|
123
|
+
isDeepStrictEqual(new Set(previous.sourceIds ?? []), new Set(bindings.sourceIds)))) {
|
|
124
|
+
const current = await client.revisions.list(appId, requestOptions);
|
|
125
|
+
if (current.version !== history.version)
|
|
126
|
+
conflict('App changed during inspection. Review the current refs.');
|
|
127
|
+
return { url: app.deployment.url, revisionId: history.published.revisionId, changed: false };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const manifest = await client.apps.uploadFiles(appId, files, requestOptions);
|
|
131
|
+
const saved = await client.apps.saveSite(appId, { ...body, site: { files: manifest, spa } }, requestOptions);
|
|
132
|
+
const ready = await client.revisions.list(appId, requestOptions);
|
|
133
|
+
if (ready.draft?.revisionId !== saved.draft.revisionId ||
|
|
134
|
+
ready.draft.sequence !== saved.draft.sequence ||
|
|
135
|
+
ready.published?.revisionId !== history.published?.revisionId ||
|
|
136
|
+
ready.published?.sequence !== history.published?.sequence) {
|
|
137
|
+
conflict('App changed during deployment. Review the current refs before retrying.');
|
|
138
|
+
}
|
|
139
|
+
const published = await client.revisions.publish(appId, { expectedVersion: ready.version }, requestOptions);
|
|
140
|
+
if (published.published?.revisionId !== saved.draft.revisionId)
|
|
141
|
+
throw new Error('Publication returned an unexpected revision. Inspect app history before retrying.');
|
|
142
|
+
return { url: app.deployment.url, revisionId: published.published.revisionId, changed: true };
|
|
143
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare class PermuteApiError extends Error {
|
|
2
|
+
readonly status: number;
|
|
3
|
+
readonly code: string;
|
|
4
|
+
readonly details?: unknown;
|
|
5
|
+
readonly requestId?: string;
|
|
6
|
+
constructor(input: {
|
|
7
|
+
status: number;
|
|
8
|
+
code: string;
|
|
9
|
+
message: string;
|
|
10
|
+
details?: unknown;
|
|
11
|
+
requestId?: string;
|
|
12
|
+
});
|
|
13
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export class PermuteApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
details;
|
|
5
|
+
requestId;
|
|
6
|
+
constructor(input) {
|
|
7
|
+
super(input.message);
|
|
8
|
+
this.name = 'PermuteApiError';
|
|
9
|
+
this.status = input.status;
|
|
10
|
+
this.code = input.code;
|
|
11
|
+
this.details = input.details;
|
|
12
|
+
this.requestId = input.requestId;
|
|
13
|
+
}
|
|
14
|
+
}
|