@permute/sdk 0.5.0 → 0.7.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 +229 -20
- package/dist/app.d.ts +16 -2
- package/dist/app.js +25 -5
- package/dist/bridge.d.ts +10 -0
- package/dist/bridge.js +62 -0
- package/dist/client.d.ts +123 -5
- package/dist/client.js +110 -100
- package/dist/deploy.d.ts +77 -5
- package/dist/deploy.js +137 -63
- package/dist/generated/openapi.d.ts +354 -13
- package/dist/generated/runtime-contract.d.ts +2 -1
- package/dist/generated/runtime-contract.js +100 -8558
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/method.d.ts +10 -0
- package/dist/method.js +21 -0
- package/dist/sourceFiles.d.ts +16 -0
- package/dist/sourceFiles.js +114 -0
- package/dist/types.d.ts +19 -0
- package/package.json +3 -2
package/dist/deploy.js
CHANGED
|
@@ -1,134 +1,208 @@
|
|
|
1
|
-
import { lstat, readdir,
|
|
2
|
-
import {
|
|
3
|
-
import { join } from 'node:path';
|
|
1
|
+
import { lstat, readdir, mkdir, writeFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { join, dirname, resolve, basename } from 'node:path';
|
|
4
3
|
import { createHash } from 'node:crypto';
|
|
5
4
|
import { isDeepStrictEqual } from 'node:util';
|
|
6
5
|
import { Ajv } from 'ajv';
|
|
7
6
|
import { PermuteApiError } from './errors.js';
|
|
8
7
|
import { publicApiRoutes } from './generated/runtime-contract.js';
|
|
8
|
+
import { readLocalFile, SourceFileSelection } from './sourceFiles.js';
|
|
9
9
|
const ajv = new Ajv({ strict: false, allErrors: true, validateFormats: false });
|
|
10
10
|
const validateUploads = ajv.compile(publicApiRoutes.appsUploadsCreate.bodySchema);
|
|
11
11
|
const validateSite = ajv.compile(publicApiRoutes.appsSiteUpdate.bodySchema);
|
|
12
|
+
const validateSource = ajv.compile(publicApiRoutes.appsSiteUpdate.bodySchema.properties.source);
|
|
12
13
|
function conflict(message) {
|
|
13
14
|
throw new PermuteApiError({ status: 409, code: 'deployment_conflict', message });
|
|
14
15
|
}
|
|
15
|
-
/**
|
|
16
|
-
async function
|
|
16
|
+
/** Capture exact bytes once, before any upload or draft mutation. */
|
|
17
|
+
async function readDirectory(directory, validate, signal, source) {
|
|
17
18
|
const entries = [];
|
|
18
|
-
const walk = async (absolutePath, path) => {
|
|
19
|
+
const walk = async (absolutePath, path, ancestors) => {
|
|
19
20
|
signal?.throwIfAborted();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
if (path && source?.blocked(path))
|
|
22
|
+
return;
|
|
23
|
+
let stat = await lstat(absolutePath);
|
|
24
|
+
if (path && source && (await source.ignored(path, stat.isDirectory(), absolutePath)))
|
|
25
|
+
return;
|
|
26
|
+
if (stat.isSymbolicLink()) {
|
|
27
|
+
if (!source)
|
|
28
|
+
throw new Error(`App directory contains a symbolic link: ${path || directory}`);
|
|
29
|
+
absolutePath = await source.resolveLink(absolutePath);
|
|
30
|
+
stat = await lstat(absolutePath);
|
|
31
|
+
if (await source.ignored(path, stat.isDirectory(), absolutePath))
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
23
34
|
if (stat.isDirectory()) {
|
|
35
|
+
const realDirectory = await realpath(absolutePath);
|
|
36
|
+
if (ancestors.has(realDirectory))
|
|
37
|
+
throw new Error(`Source symlink cycle: ${path}`);
|
|
38
|
+
const parents = new Set([...ancestors, realDirectory]);
|
|
24
39
|
for (const name of (await readdir(absolutePath)).sort()) {
|
|
25
|
-
await walk(join(absolutePath, name), path ? `${path}/${name}` : name);
|
|
40
|
+
await walk(join(absolutePath, name), path ? `${path}/${name}` : name, parents);
|
|
26
41
|
}
|
|
27
42
|
}
|
|
28
43
|
else if (stat.isFile() && path) {
|
|
29
|
-
entries.push({
|
|
30
|
-
|
|
44
|
+
entries.push({
|
|
45
|
+
path,
|
|
46
|
+
absolutePath,
|
|
47
|
+
size: stat.size,
|
|
48
|
+
sha256: '0'.repeat(64),
|
|
49
|
+
...(source && (stat.mode & 0o111) !== 0 ? { executable: true } : {}),
|
|
50
|
+
});
|
|
51
|
+
if (entries.reduce((total, file) => total + file.size, 0) > 50 * 1024 * 1024 ||
|
|
52
|
+
!validateUploads({ files: entries.map(({ absolutePath: _, executable: __, ...file }) => file) })) {
|
|
31
53
|
throw new PermuteApiError({
|
|
32
54
|
status: 0,
|
|
33
55
|
code: 'invalid_build',
|
|
34
|
-
message: '
|
|
56
|
+
message: 'App files exceed size limits or contain an unsupported path.',
|
|
35
57
|
details: validateUploads.errors,
|
|
36
58
|
});
|
|
37
59
|
}
|
|
38
60
|
}
|
|
39
61
|
else
|
|
40
|
-
throw new Error(`Unsupported
|
|
62
|
+
throw new Error(`Unsupported app file: ${path || directory}`);
|
|
41
63
|
};
|
|
42
|
-
await walk(directory, '');
|
|
64
|
+
await walk(directory, '', new Set());
|
|
43
65
|
const manifest = entries.map(({ absolutePath: _, ...file }) => file);
|
|
44
66
|
// JSON Schema cannot express the server's manifest refinements. Check these before uploading.
|
|
45
|
-
if (!manifest
|
|
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 } })) {
|
|
67
|
+
if (!validate(manifest)) {
|
|
50
68
|
throw new PermuteApiError({
|
|
51
69
|
status: 0,
|
|
52
70
|
code: 'invalid_build',
|
|
53
|
-
message: 'Invalid app
|
|
54
|
-
details: validateSite.errors,
|
|
71
|
+
message: 'Invalid app files or settings. Check file paths, size limits and build instructions.',
|
|
55
72
|
});
|
|
56
73
|
}
|
|
57
74
|
const files = [];
|
|
58
75
|
for (const entry of entries) {
|
|
59
76
|
signal?.throwIfAborted();
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
}
|
|
77
|
+
const data = source?.ignoreContents(entry.path) ?? (await readLocalFile(entry.absolutePath, entry.size, signal));
|
|
78
|
+
if (data.length !== entry.size)
|
|
79
|
+
throw new Error(`App file changed: ${entry.path}`);
|
|
80
|
+
files.push({ path: entry.path, data, ...(entry.executable ? { executable: true } : {}) });
|
|
81
81
|
}
|
|
82
82
|
return files;
|
|
83
83
|
}
|
|
84
|
+
function validSource(source) {
|
|
85
|
+
if (!validateSource(source))
|
|
86
|
+
return false;
|
|
87
|
+
const paths = source.files.map((file) => file.path.toLowerCase());
|
|
88
|
+
return (safeSourcePath(source.build.outputDirectory) &&
|
|
89
|
+
source.files.reduce((total, file) => total + file.size, 0) <= 50 * 1024 * 1024 &&
|
|
90
|
+
new Set(paths).size === paths.length &&
|
|
91
|
+
paths.every((path) => safeSourcePath(path) &&
|
|
92
|
+
!paths.some((other) => other !== path && other.startsWith(`${path}/`)) &&
|
|
93
|
+
path !== source.build.outputDirectory.toLowerCase() &&
|
|
94
|
+
!path.startsWith(`${source.build.outputDirectory.toLowerCase()}/`)));
|
|
95
|
+
}
|
|
96
|
+
function safeSourcePath(path) {
|
|
97
|
+
return (!SourceFileSelection.excluded(path) &&
|
|
98
|
+
path
|
|
99
|
+
.split('/')
|
|
100
|
+
.every((part) => part !== '.' &&
|
|
101
|
+
part !== '..' &&
|
|
102
|
+
!part.endsWith('.') &&
|
|
103
|
+
!part.endsWith(' ') &&
|
|
104
|
+
!/^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i.test(part)));
|
|
105
|
+
}
|
|
106
|
+
function manifest(files) {
|
|
107
|
+
return files.map(({ path, data, executable }) => ({
|
|
108
|
+
path,
|
|
109
|
+
size: data.length,
|
|
110
|
+
sha256: createHash('sha256').update(data).digest('hex'),
|
|
111
|
+
...(executable ? { executable: true } : {}),
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
function sameFiles(left, right) {
|
|
115
|
+
return isDeepStrictEqual(new Map(left.map((file) => [file.path, file])), new Map(right.map((file) => [file.path, file])));
|
|
116
|
+
}
|
|
117
|
+
async function readSource(source, signal) {
|
|
118
|
+
const build = { ...source.build, command: source.build.command.trim() };
|
|
119
|
+
const directory = await realpath(source.directory);
|
|
120
|
+
const files = await readDirectory(directory, (files) => validSource({ files, build }), signal, new SourceFileSelection(directory, build.outputDirectory, signal));
|
|
121
|
+
return { files, value: { files: manifest(files), build } };
|
|
122
|
+
}
|
|
123
|
+
/** Save a shared source checkpoint without compiling or publishing. */
|
|
124
|
+
export async function saveAppSource(client, options, requestOptions) {
|
|
125
|
+
const input = structuredClone(options);
|
|
126
|
+
const source = await readSource(input.source, requestOptions?.signal);
|
|
127
|
+
await client.apps.uploadFiles(input.appId, source.files, requestOptions);
|
|
128
|
+
return client.apps.saveSite(input.appId, { source: source.value, expectedDraft: input.expectedDraft }, requestOptions);
|
|
129
|
+
}
|
|
130
|
+
/** Materialize the draft or an exact historical source revision into a new folder. Never overwrites files. */
|
|
131
|
+
export async function checkoutApp(client, options, requestOptions) {
|
|
132
|
+
const { appId, directory, revisionId: selected } = structuredClone(options);
|
|
133
|
+
const history = await client.revisions.list(appId, requestOptions);
|
|
134
|
+
const revisionId = selected ?? history.draft?.revisionId;
|
|
135
|
+
if (!revisionId)
|
|
136
|
+
throw new Error('App has no draft.');
|
|
137
|
+
const revision = await client.revisions.get(appId, revisionId, requestOptions);
|
|
138
|
+
const source = revision.value.source;
|
|
139
|
+
if (!validSource(source))
|
|
140
|
+
throw new Error('This app revision has no valid stored source.');
|
|
141
|
+
const destination = resolve(directory);
|
|
142
|
+
const root = join(await realpath(dirname(destination)), basename(destination));
|
|
143
|
+
await mkdir(root); // Exclusive creation refuses existing folders, files, and symlinks.
|
|
144
|
+
for (const file of source.files) {
|
|
145
|
+
requestOptions?.signal?.throwIfAborted();
|
|
146
|
+
const content = await client.apps.sourceFile(appId, { revisionId, path: file.path }, requestOptions);
|
|
147
|
+
const bytes = Buffer.from(content.data, 'base64');
|
|
148
|
+
if (bytes.length !== file.size || createHash('sha256').update(bytes).digest('hex') !== file.sha256)
|
|
149
|
+
throw new Error(`Source file failed integrity verification: ${file.path}`);
|
|
150
|
+
const target = join(root, file.path);
|
|
151
|
+
await mkdir(dirname(target), { recursive: true });
|
|
152
|
+
await writeFile(target, bytes, { flag: 'wx', mode: file.executable ? 0o755 : 0o644 });
|
|
153
|
+
}
|
|
154
|
+
return { revisionId, source, expectedDraft: revisionId === history.draft?.revisionId ? history.draft : null };
|
|
155
|
+
}
|
|
84
156
|
/** Upload, save, and publish an existing live app. Builds and provisions nothing; never retries mutations. */
|
|
85
157
|
export async function deployApp(client, options, requestOptions) {
|
|
86
|
-
const { appId, directory, spa = true, ...bindings } = structuredClone(options);
|
|
158
|
+
const { appId, directory, source: sourceOptions, spa = true, ...bindings } = structuredClone(options);
|
|
87
159
|
const app = await client.apps.get(appId, requestOptions);
|
|
88
160
|
if (app.deployment?.status !== 'live')
|
|
89
161
|
throw new Error('App hosting must be live before deploying. Inspect client.apps.get(appId).');
|
|
90
162
|
const history = await client.revisions.list(appId, requestOptions);
|
|
91
|
-
const expected = bindings.expectedDraft
|
|
163
|
+
const expected = bindings.expectedDraft;
|
|
164
|
+
if (!expected)
|
|
165
|
+
throw new Error('expectedDraft is required. Use the token from checkout or a reviewed app draft.');
|
|
92
166
|
if (history.draft?.revisionId !== expected.revisionId ||
|
|
93
167
|
history.draft.sequence !== expected.sequence ||
|
|
94
168
|
app.draft.revisionId !== expected.revisionId ||
|
|
95
169
|
app.draft.sequence !== expected.sequence) {
|
|
96
170
|
conflict('App draft changed. Read and review it before deploying.');
|
|
97
171
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
172
|
+
const source = await readSource(sourceOptions, requestOptions?.signal);
|
|
173
|
+
const body = { ...bindings, source: source.value, expectedDraft: expected };
|
|
174
|
+
const files = await readDirectory(directory, (files) => files.some((file) => file.path === 'index.html') &&
|
|
175
|
+
new Set(body.actions?.map((action) => action.actionId)).size === (body.actions?.length ?? 0) &&
|
|
176
|
+
validateSite({ ...body, site: { files, spa } }), requestOptions?.signal);
|
|
103
177
|
// Only a clean published draft can be skipped: an approved draft may also contain settings edits.
|
|
104
178
|
if (history.published && history.draft.revisionId === history.published.revisionId) {
|
|
105
179
|
const revision = await client.revisions.get(appId, history.published.revisionId, requestOptions);
|
|
106
180
|
const previous = {
|
|
107
181
|
expectedDraft: expected,
|
|
108
|
-
|
|
182
|
+
source: revision.value.source,
|
|
183
|
+
site: revision.value.site && typeof revision.value.site === 'object'
|
|
184
|
+
? Object.fromEntries(Object.entries(revision.value.site).filter(([key]) => key !== 'sourceHash'))
|
|
185
|
+
: revision.value.site,
|
|
109
186
|
actions: revision.value.actions,
|
|
110
|
-
sourceIds: revision.value.sourceIds,
|
|
111
187
|
};
|
|
112
|
-
const localManifest = files
|
|
113
|
-
path,
|
|
114
|
-
size: data.length,
|
|
115
|
-
sha256: createHash('sha256').update(data).digest('hex'),
|
|
116
|
-
}));
|
|
188
|
+
const localManifest = manifest(files);
|
|
117
189
|
if (validateSite(previous) &&
|
|
190
|
+
isDeepStrictEqual(previous.source.build, source.value.build) &&
|
|
191
|
+
sameFiles(previous.source.files, source.value.files) &&
|
|
192
|
+
previous.site &&
|
|
118
193
|
previous.site.spa === spa &&
|
|
119
|
-
|
|
194
|
+
sameFiles(previous.site.files, localManifest) &&
|
|
120
195
|
(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)))) {
|
|
196
|
+
isDeepStrictEqual(new Map((previous.actions ?? []).map((action) => [action.actionId, action.revisionId])), new Map(bindings.actions.map((action) => [action.actionId, action.revisionId]))))) {
|
|
124
197
|
const current = await client.revisions.list(appId, requestOptions);
|
|
125
198
|
if (current.version !== history.version)
|
|
126
199
|
conflict('App changed during inspection. Review the current refs.');
|
|
127
200
|
return { url: app.deployment.url, revisionId: history.published.revisionId, changed: false };
|
|
128
201
|
}
|
|
129
202
|
}
|
|
130
|
-
|
|
131
|
-
const
|
|
203
|
+
await client.apps.uploadFiles(appId, source.files, requestOptions);
|
|
204
|
+
const siteFiles = await client.apps.uploadFiles(appId, files, requestOptions);
|
|
205
|
+
const saved = await client.apps.saveSite(appId, { ...body, site: { files: siteFiles, spa } }, requestOptions);
|
|
132
206
|
const ready = await client.revisions.list(appId, requestOptions);
|
|
133
207
|
if (ready.draft?.revisionId !== saved.draft.revisionId ||
|
|
134
208
|
ready.draft.sequence !== saved.draft.sequence ||
|