@corva/create-app 0.127.0 → 0.128.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
@@ -190,11 +190,47 @@ Options:
190
190
  --silent [boolean] Only log result of the operation (default: false)
191
191
  --remove-on-success App package (.zip) will not be deleted after upload (default: true)
192
192
  --remove-existing [boolean] If package.json version is already taken - remove the previously published package and upload a new one (default: false)
193
+ --zip-file-name <string> Upload an existing ZIP without recreating it or bumping its version
194
+ --prebuilt Skip the Corva build for an existing ZIP (requires --zip-file-name)
193
195
  --author [string] Author name for the audit
194
196
  ```
195
197
 
196
198
  ### Examples
197
199
 
200
+ #### Release a locally built package
201
+
202
+ Build the app and install its dependencies in your own CI, then upload the prepared ZIP:
203
+
204
+ ```sh
205
+ create-corva-app release . --zip-file-name app-prebuilt.zip --prebuilt \
206
+ --env production --api-key "$CORVA_API_KEY" --label BETA --notes "Prebuilt release"
207
+ ```
208
+
209
+ The CLI uploads the ZIP unchanged, waits for deployment, and publishes it with the requested label
210
+ and notes. It does not build, install dependencies, bump versions, or delete the supplied ZIP.
211
+ Without `--prebuilt`, Corva uses its normal build process, including when `--zip-file-name` is supplied.
212
+
213
+ Keep `manifest.json` in the project directory, and `package.json` for JavaScript apps: the CLI uses
214
+ these files to identify the app and, with `--remove-existing`, its version. They must match the ZIP.
215
+ Set the final app key and version **before** compiling an FE app; the bundle embeds this identity.
216
+ With `--prebuilt`, the CLI checks the ZIP structure and relevant metadata before contacting Corva.
217
+ It checks root manifest/app metadata, FE `app.js`, JavaScript package versions, and Python entrypoint and
218
+ installed-dependency layout. Duplicate and unsafe archive paths are rejected. Metadata files are
219
+ limited to 1 MiB each. It does not rewrite the ZIP or verify imports, embedded FE identity, or native
220
+ dependency compatibility; deployment and runtime testing are still needed.
221
+
222
+ ZIP entries must be at the archive root, without an enclosing project folder:
223
+
224
+ - **FE:** `manifest.json`, `package.json`, and the contents of `dist/` (including `app.js`, chunks,
225
+ and any required assets). Do not include `dist/` itself, source code, `node_modules/`, or an offline mirror.
226
+ - **Python BE:** `manifest.json`, application code at the manifest's entrypoint path,
227
+ `requirements.txt`, and installed runtime dependencies under `requirements/`.
228
+ Build dependencies for Linux and the Python version and CPU architecture declared in the manifest;
229
+ macOS or Windows dependencies may not run on Lambda. Include all runtime dependencies because
230
+ Corva skips dependency installation.
231
+
232
+ Prebuilt uploads still use the platform's upload and runtime limits; this option does not increase them.
233
+
198
234
  #### Make a release with ask for version
199
235
 
200
236
  ```sh
@@ -38,8 +38,13 @@ export const releaseCommand = new Command('release')
38
38
  ).default(false),
39
39
  )
40
40
  .addOption(zipFileNameOption)
41
+ .addOption(new Option('--prebuilt', 'Skip the Corva build for an existing ZIP (requires --zip-file-name)'))
41
42
  .addOption(new Option('--author [string]', 'Author name for the audit'))
42
43
  .action(async (dirName, patterns, options) => {
44
+ if (options.prebuilt && !options.zipFileName) {
45
+ releaseCommand.error('--prebuilt requires --zip-file-name');
46
+ }
47
+
43
48
  // if author is present in CLI, save it to process.env
44
49
  if (options.author) {
45
50
  process.env.githubUsername = options.author;
@@ -0,0 +1,139 @@
1
+ import { posix } from 'node:path';
2
+ import yauzl from 'yauzl';
3
+ import { StepError } from './step-error.js';
4
+
5
+ export async function validatePrebuiltZip(zipPath, { manifest, pkg, options }) {
6
+ let zip;
7
+
8
+ try {
9
+ zip = await yauzl.openPromise(zipPath, { autoClose: false, strictFileNames: true });
10
+
11
+ const entries = new Map();
12
+
13
+ for await (const entry of zip.eachEntry()) {
14
+ const name = posix.normalize(entry.fileName);
15
+
16
+ if (entries.has(name)) {
17
+ throw new Error(`Duplicate ZIP entry: ${name}`);
18
+ }
19
+
20
+ entries.set(name, entry);
21
+ }
22
+
23
+ const requireFile = (name) => {
24
+ const entry = entries.get(name);
25
+
26
+ if (!entry || entry.fileName.endsWith('/')) {
27
+ const nested = [...entries.keys()].find((path) => path.endsWith(`/${name}`));
28
+
29
+ throw new Error(
30
+ `${name} is missing at the ZIP root.${
31
+ nested ? ` Found ${nested}; ZIP the contents of the folder, not the folder itself.` : ''
32
+ }`,
33
+ );
34
+ }
35
+
36
+ return entry;
37
+ };
38
+
39
+ const readText = async (name) => {
40
+ const entry = requireFile(name);
41
+
42
+ if (entry.uncompressedSize > 1024 * 1024) {
43
+ throw new Error(`${name} exceeds the 1 MiB metadata limit`);
44
+ }
45
+
46
+ const stream = await zip.openReadStreamPromise(entry);
47
+ const chunks = [];
48
+
49
+ for await (const chunk of stream) {
50
+ chunks.push(chunk);
51
+ }
52
+
53
+ return Buffer.concat(chunks).toString('utf8');
54
+ };
55
+
56
+ const readJson = async (name) => {
57
+ try {
58
+ const value = JSON.parse(await readText(name));
59
+
60
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
61
+ throw new Error('expected a JSON object');
62
+ }
63
+
64
+ return value;
65
+ } catch (error) {
66
+ throw new Error(`${name}: ${error.message}`);
67
+ }
68
+ };
69
+
70
+ const archivedManifest = await readJson('manifest.json');
71
+ const expected = manifest.manifest;
72
+
73
+ for (const [field, actual, required] of [
74
+ ['application.key', archivedManifest.application?.key, expected.application.key],
75
+ ['application.type', archivedManifest.application?.type, expected.application.type],
76
+ ['settings.runtime', archivedManifest.settings?.runtime, expected.settings.runtime],
77
+ ]) {
78
+ if (!required || actual !== required) {
79
+ throw new Error(`manifest.json ${field} must match the release project (${required})`);
80
+ }
81
+ }
82
+
83
+ if (options.appKey && options.appKey !== archivedManifest.application.key) {
84
+ throw new Error('manifest.json application.key must match --app-key');
85
+ }
86
+
87
+ if (manifest.isUi()) {
88
+ if (!requireFile('app.js').uncompressedSize) {
89
+ throw new Error('app.js must not be empty');
90
+ }
91
+ }
92
+
93
+ if (manifest.isJs()) {
94
+ const archivedPackage = await readJson('package.json');
95
+
96
+ if (!pkg.version || archivedPackage.version !== pkg.version) {
97
+ throw new Error(`package.json version must match the release project (${pkg.version})`);
98
+ }
99
+ } else if (manifest.isPython()) {
100
+ const settings = archivedManifest.settings;
101
+
102
+ for (const field of ['file', 'function']) {
103
+ if (
104
+ typeof settings.entrypoint?.[field] !== 'string' ||
105
+ !settings.entrypoint[field] ||
106
+ settings.entrypoint[field] !== expected.settings.entrypoint?.[field]
107
+ ) {
108
+ throw new Error(`manifest.json settings.entrypoint.${field} must match the release project`);
109
+ }
110
+ }
111
+
112
+ if (expected.settings.cpu_architecture && settings.cpu_architecture !== expected.settings.cpu_architecture) {
113
+ throw new Error('manifest.json settings.cpu_architecture must match the release project');
114
+ }
115
+
116
+ const modulePath = settings.entrypoint.file.replaceAll('.', '/');
117
+
118
+ if (!entries.has(`${modulePath}.py`) && !entries.has(`${modulePath}/__init__.py`)) {
119
+ throw new Error(`Python entrypoint is missing: expected ${modulePath}.py or ${modulePath}/__init__.py`);
120
+ }
121
+
122
+ const requirements = await readText('requirements.txt');
123
+ const hasDependencies = requirements.split(/\r?\n/).some((line) => line.trim() && !line.trim().startsWith('#'));
124
+
125
+ if (
126
+ hasDependencies &&
127
+ ![...entries].some(
128
+ ([name, entry]) => name.startsWith('requirements/') && !name.endsWith('/') && entry.uncompressedSize,
129
+ )
130
+ ) {
131
+ throw new Error('requirements.txt declares dependencies, but requirements/ contains no installed dependencies');
132
+ }
133
+ }
134
+ } catch (error) {
135
+ throw new StepError(`Invalid prebuilt archive: ${error.message}`, { cause: error });
136
+ } finally {
137
+ zip?.close();
138
+ }
139
+ }
@@ -14,11 +14,11 @@ export const RELEASE_FLOW = {
14
14
  name: 'release',
15
15
  steps: [
16
16
  PREPARE_FLOW,
17
- SETUP_API_CLIENT_STEP,
18
- RELEASE_PREPARE_DATA_STEP,
19
17
  // With --zip-file-name the archive already exists, so the zip flow is skipped entirely. That
20
18
  // also lets runtimes the zip flow has no file list for (e.g. `provided.*`) be released.
21
19
  RESOLVE_PREBUILT_ZIP_STEP,
20
+ SETUP_API_CLIENT_STEP,
21
+ RELEASE_PREPARE_DATA_STEP,
22
22
  ZIP_SIMPLE_FLOW,
23
23
  UPLOAD_ZIP_TO_CORVA_STEP,
24
24
  WAIT_FOR_BUILD_FINISH_STEP,
@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
 
4
4
  import { StepError } from '../../lib/step-error.js';
5
+ import { validatePrebuiltZip } from '../../lib/validate-prebuilt-zip.js';
5
6
 
6
7
  /**
7
8
  * Handles `--zip-file-name`: the caller has already built the package archive, so this records it
@@ -9,7 +10,7 @@ import { StepError } from '../../lib/step-error.js';
9
10
  */
10
11
  export const RESOLVE_PREBUILT_ZIP_STEP = {
11
12
  message: 'Resolving prebuilt archive...',
12
- fn: async ({ dirName, options }) => {
13
+ fn: async ({ dirName, options, manifest, pkg }) => {
13
14
  const { zipFileName } = options;
14
15
 
15
16
  if (!zipFileName) {
@@ -24,6 +25,10 @@ export const RESOLVE_PREBUILT_ZIP_STEP = {
24
25
  throw new StepError(`Prebuilt archive ${zipFileName} not found in ${dirName}`);
25
26
  }
26
27
 
28
+ if (options.prebuilt) {
29
+ await validatePrebuiltZip(zipPath, { manifest, pkg, options });
30
+ }
31
+
27
32
  return {
28
33
  zipFileName,
29
34
  skipZip: true,
@@ -102,6 +102,10 @@ export const UPLOAD_ZIP_TO_CORVA_STEP = {
102
102
 
103
103
  form.append('package', createReadStream(resolve(dirName, zipFileName)), 'package.zip');
104
104
 
105
+ if (options.prebuilt) {
106
+ form.append('prebuilt', 'true');
107
+ }
108
+
105
109
  let githubUsername = getGithubUsernameActor();
106
110
 
107
111
  if (!githubUsername) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@corva/create-app",
3
- "version": "0.127.0",
3
+ "version": "0.128.0",
4
4
  "private": false,
5
5
  "description": "Create an app to use it in CORVA.AI",
6
6
  "keywords": [
@@ -60,7 +60,8 @@
60
60
  "is-glob": "^4.0.3",
61
61
  "lodash": "^4.18.1",
62
62
  "semver": "^7.3.2",
63
- "terminal-link": "^3.0.0"
63
+ "terminal-link": "^3.0.0",
64
+ "yauzl": "^3.4.0"
64
65
  },
65
66
  "devDependencies": {
66
67
  "@babel/core": "^7.11.0",