@permute/sdk 0.6.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/dist/deploy.js CHANGED
@@ -1,120 +1,197 @@
1
- import { lstat, readdir, open } from 'node:fs/promises';
2
- import { constants } from 'node:fs';
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
- /** Node/Bun only. Validate the complete build before making any upload or draft mutation. */
16
- async function readBuild(directory, body, spa, signal) {
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
- const stat = await lstat(absolutePath);
21
- if (stat.isSymbolicLink())
22
- throw new Error(`Build contains a symbolic link: ${path || directory}`);
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({ path, absolutePath, size: stat.size, sha256: '0'.repeat(64) });
30
- if (!validateUploads({ files: entries.map(({ absolutePath: _, ...file }) => file) })) {
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: 'Build files exceed app limits or contain an unsupported path.',
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 build entry: ${path || directory}`);
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.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
- !validateSite({ ...body, site: { files: manifest, spa } })) {
67
+ if (!validate(manifest)) {
49
68
  throw new PermuteApiError({
50
69
  status: 0,
51
70
  code: 'invalid_build',
52
- message: 'Invalid app build or bindings. Include index.html, stay within 50 MiB, and use valid app settings.',
53
- details: validateSite.errors,
71
+ message: 'Invalid app files or settings. Check file paths, size limits and build instructions.',
54
72
  });
55
73
  }
56
74
  const files = [];
57
75
  for (const entry of entries) {
58
76
  signal?.throwIfAborted();
59
- const file = await open(entry.absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
60
- try {
61
- const stat = await file.stat();
62
- if (!stat.isFile() || stat.size !== entry.size)
63
- throw new Error(`Build changed: ${entry.path}`);
64
- const data = Buffer.alloc(entry.size + 1);
65
- let length = 0;
66
- while (length < data.byteLength) {
67
- signal?.throwIfAborted();
68
- const { bytesRead } = await file.read(data, length, data.byteLength - length, null);
69
- if (!bytesRead)
70
- break;
71
- length += bytesRead;
72
- }
73
- if (length !== entry.size)
74
- throw new Error(`Build changed: ${entry.path}`);
75
- files.push({ path: entry.path, data: data.subarray(0, length) });
76
- }
77
- finally {
78
- await file.close();
79
- }
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 } : {}) });
80
81
  }
81
82
  return files;
82
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
+ }
83
156
  /** Upload, save, and publish an existing live app. Builds and provisions nothing; never retries mutations. */
84
157
  export async function deployApp(client, options, requestOptions) {
85
- const { appId, directory, spa = true, ...bindings } = structuredClone(options);
158
+ const { appId, directory, source: sourceOptions, spa = true, ...bindings } = structuredClone(options);
86
159
  const app = await client.apps.get(appId, requestOptions);
87
160
  if (app.deployment?.status !== 'live')
88
161
  throw new Error('App hosting must be live before deploying. Inspect client.apps.get(appId).');
89
162
  const history = await client.revisions.list(appId, requestOptions);
90
- const expected = bindings.expectedDraft ?? app.draft;
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.');
91
166
  if (history.draft?.revisionId !== expected.revisionId ||
92
167
  history.draft.sequence !== expected.sequence ||
93
168
  app.draft.revisionId !== expected.revisionId ||
94
169
  app.draft.sequence !== expected.sequence) {
95
170
  conflict('App draft changed. Read and review it before deploying.');
96
171
  }
97
- if (!bindings.expectedDraft && history.draft.revisionId !== history.published?.revisionId) {
98
- conflict('App has an unpublished draft. Review it and supply its expectedDraft to replace it.');
99
- }
100
- const body = { ...bindings, expectedDraft: expected };
101
- const files = await readBuild(directory, body, spa, requestOptions?.signal);
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);
102
177
  // Only a clean published draft can be skipped: an approved draft may also contain settings edits.
103
178
  if (history.published && history.draft.revisionId === history.published.revisionId) {
104
179
  const revision = await client.revisions.get(appId, history.published.revisionId, requestOptions);
105
180
  const previous = {
106
181
  expectedDraft: expected,
107
- site: revision.value.site,
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,
108
186
  actions: revision.value.actions,
109
187
  };
110
- const localManifest = files.map(({ path, data }) => ({
111
- path,
112
- size: data.length,
113
- sha256: createHash('sha256').update(data).digest('hex'),
114
- }));
188
+ const localManifest = manifest(files);
115
189
  if (validateSite(previous) &&
190
+ isDeepStrictEqual(previous.source.build, source.value.build) &&
191
+ sameFiles(previous.source.files, source.value.files) &&
192
+ previous.site &&
116
193
  previous.site.spa === spa &&
117
- isDeepStrictEqual(new Map(previous.site.files.map((file) => [file.path, file])), new Map(localManifest.map((file) => [file.path, file]))) &&
194
+ sameFiles(previous.site.files, localManifest) &&
118
195
  (bindings.actions === undefined ||
119
196
  isDeepStrictEqual(new Map((previous.actions ?? []).map((action) => [action.actionId, action.revisionId])), new Map(bindings.actions.map((action) => [action.actionId, action.revisionId]))))) {
120
197
  const current = await client.revisions.list(appId, requestOptions);
@@ -123,8 +200,9 @@ export async function deployApp(client, options, requestOptions) {
123
200
  return { url: app.deployment.url, revisionId: history.published.revisionId, changed: false };
124
201
  }
125
202
  }
126
- const manifest = await client.apps.uploadFiles(appId, files, requestOptions);
127
- const saved = await client.apps.saveSite(appId, { ...body, site: { files: manifest, spa } }, requestOptions);
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);
128
206
  const ready = await client.revisions.list(appId, requestOptions);
129
207
  if (ready.draft?.revisionId !== saved.draft.revisionId ||
130
208
  ready.draft.sequence !== saved.draft.sequence ||