@examplary/cli 1.14.0 → 1.17.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 +21 -20
- package/dist/commands/init.d.ts +14 -0
- package/dist/commands/init.js +103 -0
- package/dist/commands/package.d.ts +1 -0
- package/dist/commands/package.js +16 -0
- package/dist/commands/publish.d.ts +5 -0
- package/dist/commands/publish.js +57 -0
- package/dist/commands/set-secret.d.ts +6 -0
- package/dist/commands/set-secret.js +29 -0
- package/dist/commands/validate.d.ts +0 -1
- package/dist/commands/validate.js +2 -20
- package/dist/index.js +38 -29
- package/dist/lib/api.d.ts +1 -1
- package/dist/lib/api.js +17 -6
- package/dist/lib/build-app.d.ts +21 -0
- package/dist/lib/build-app.js +179 -0
- package/dist/lib/build-current-app.d.ts +4 -0
- package/dist/lib/build-current-app.js +21 -0
- package/dist/lib/build-question-type.d.ts +8 -0
- package/dist/lib/build-question-type.js +48 -0
- package/dist/lib/bundle.d.ts +4 -3
- package/dist/lib/bundle.js +9 -31
- package/dist/lib/styles.d.ts +2 -1
- package/dist/lib/styles.js +3 -1
- package/dist/lib/validate.d.ts +2 -4
- package/dist/lib/validate.js +7 -26
- package/package.json +4 -24
- package/dist/commands/preview.d.ts +0 -5
- package/dist/commands/preview.js +0 -195
- package/dist/commands/upload.d.ts +0 -5
- package/dist/commands/upload.js +0 -114
- package/dist/lib/read-meta.d.ts +0 -20
- package/dist/lib/read-meta.js +0 -44
- package/dist/lib/safe-path.d.ts +0 -5
- package/dist/lib/safe-path.js +0 -13
- package/dist/preview-ui/assets/icon-nodes-CxaTPv-R.js +0 -2
- package/dist/preview-ui/assets/icon-nodes-CxaTPv-R.js.map +0 -1
- package/dist/preview-ui/assets/index-b38eDKFI.js +0 -3663
- package/dist/preview-ui/assets/index-b38eDKFI.js.map +0 -1
- package/dist/preview-ui/assets/index-boullcXq.css +0 -1
- package/dist/preview-ui/assets/tags-BUvo71XG.js +0 -2
- package/dist/preview-ui/assets/tags-BUvo71XG.js.map +0 -1
- package/dist/preview-ui/index.html +0 -21
package/README.md
CHANGED
|
@@ -1,36 +1,37 @@
|
|
|
1
1
|
# Examplary CLI
|
|
2
2
|
|
|
3
|
-
This package provides a command-line tool to develop and
|
|
4
|
-
the
|
|
3
|
+
This package provides a command-line tool to develop and publish apps for the Examplary
|
|
4
|
+
platform, including the question types they provide.
|
|
5
5
|
|
|
6
|
-
Read more in the [📘 Developer Documentation](https://examplary.ai/developers/
|
|
6
|
+
Read more in the [📘 Developer Documentation](https://examplary.ai/developers/apps).
|
|
7
7
|
|
|
8
|
-
##
|
|
8
|
+
## Question types
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
Question types are published as part of an app, with `app.addCapability(new QuestionType({...}))` from
|
|
11
|
+
[`@examplary/apps`](../apps-runtime/README.md). Their JSONata expressions, response processing
|
|
12
|
+
test cases, components and icon are checked whenever the app is validated or uploaded.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
npx @examplary/cli preview
|
|
15
|
-
```
|
|
14
|
+
## Apps
|
|
16
15
|
|
|
17
|
-
To
|
|
18
|
-
that contains a `question-type.json` or `question-type.yml` file:
|
|
16
|
+
To start a new app:
|
|
19
17
|
|
|
20
18
|
```bash
|
|
21
|
-
npx @examplary/cli
|
|
19
|
+
npx @examplary/cli init my-app --id com.acme.my-app --name "My app"
|
|
22
20
|
```
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
environment variable to avoid passing the key every time.
|
|
27
|
-
|
|
28
|
-
## Validating
|
|
22
|
+
This creates an `app.ts`, and a `package.json` with `validate` and `publish` scripts, and
|
|
23
|
+
installs the dependencies (`--no-install` to skip that).
|
|
29
24
|
|
|
30
|
-
|
|
25
|
+
In a directory with an `app.ts` (or `app.js`) that default-exports `defineApp()` from
|
|
26
|
+
[`@examplary/apps`](../apps-runtime/README.md):
|
|
31
27
|
|
|
32
28
|
```bash
|
|
33
|
-
npx @examplary/cli validate
|
|
29
|
+
npx @examplary/cli validate # bundle everything and check the app, without publishing
|
|
30
|
+
npx @examplary/cli publish # the same, then upload the files and publish the app
|
|
31
|
+
npx @examplary/cli package # the same, but write the files and app.json to .bundled/
|
|
32
|
+
npx @examplary/cli set-secret NAME value # set a secret, read in hooks from env
|
|
34
33
|
```
|
|
35
34
|
|
|
36
|
-
|
|
35
|
+
`publish` and `set-secret` take a `--key` argument with your API key, which you can
|
|
36
|
+
obtain from your Examplary account settings (or the `EXAMPLARY_API_KEY` environment variable),
|
|
37
|
+
and a `--host`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type InitOptions = {
|
|
2
|
+
id?: string;
|
|
3
|
+
name?: string;
|
|
4
|
+
};
|
|
5
|
+
/** Creates a new app in `dir`, which has to be empty or not exist yet. */
|
|
6
|
+
export declare const initApp: (dir: string, options?: InitOptions) => Promise<{
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
}>;
|
|
10
|
+
export declare const initCommand: (argv: InitOptions & {
|
|
11
|
+
directory?: string;
|
|
12
|
+
install: boolean;
|
|
13
|
+
}) => Promise<void>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { mkdir, readdir, writeFile } from "fs/promises";
|
|
3
|
+
import { basename, join, relative, resolve } from "path";
|
|
4
|
+
import { cwd, env, exit, platform } from "process";
|
|
5
|
+
const latestVersion = async (pkg) => {
|
|
6
|
+
try {
|
|
7
|
+
const res = await fetch(`https://registry.npmjs.org/${pkg}/latest`);
|
|
8
|
+
if (!res.ok)
|
|
9
|
+
throw new Error(`HTTP ${res.status}`);
|
|
10
|
+
return `^${(await res.json()).version}`;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return "latest";
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const appTs = (id, name) => `import { defineApp } from "@examplary/apps";
|
|
17
|
+
|
|
18
|
+
const app = defineApp({
|
|
19
|
+
id: "${id}",
|
|
20
|
+
name: { en: "${name}" },
|
|
21
|
+
description: { en: "Describe what your app adds to Examplary" },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export default app;
|
|
25
|
+
`;
|
|
26
|
+
const tsconfig = {
|
|
27
|
+
compilerOptions: {
|
|
28
|
+
target: "ES2022",
|
|
29
|
+
module: "ESNext",
|
|
30
|
+
moduleResolution: "bundler",
|
|
31
|
+
jsx: "react-jsx",
|
|
32
|
+
strict: true,
|
|
33
|
+
skipLibCheck: true,
|
|
34
|
+
noEmit: true,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
38
|
+
/** Creates a new app in `dir`, which has to be empty or not exist yet. */
|
|
39
|
+
export const initApp = async (dir, options = {}) => {
|
|
40
|
+
const files = await readdir(dir).catch(() => []);
|
|
41
|
+
if (files.some((file) => !file.startsWith("."))) {
|
|
42
|
+
throw new Error(`${dir} isn't empty`);
|
|
43
|
+
}
|
|
44
|
+
const slug = basename(dir)
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
47
|
+
.replace(/^-|-$/g, "");
|
|
48
|
+
const id = options.id ?? `com.example.${slug}`;
|
|
49
|
+
const name = options.name ??
|
|
50
|
+
slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, " ");
|
|
51
|
+
const packageJson = {
|
|
52
|
+
name: slug,
|
|
53
|
+
private: true,
|
|
54
|
+
scripts: {
|
|
55
|
+
validate: "exp validate",
|
|
56
|
+
publish: "exp publish",
|
|
57
|
+
},
|
|
58
|
+
devDependencies: {
|
|
59
|
+
"@examplary/apps": await latestVersion("@examplary/apps"),
|
|
60
|
+
"@examplary/cli": await latestVersion("@examplary/cli"),
|
|
61
|
+
"@examplary/ui": await latestVersion("@examplary/ui"),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
await mkdir(dir, { recursive: true });
|
|
65
|
+
await writeFile(join(dir, "app.ts"), appTs(id, name));
|
|
66
|
+
await writeFile(join(dir, "package.json"), json(packageJson));
|
|
67
|
+
await writeFile(join(dir, "tsconfig.json"), json(tsconfig));
|
|
68
|
+
await writeFile(join(dir, ".gitignore"), "node_modules\n.bundled\n");
|
|
69
|
+
return { id, name };
|
|
70
|
+
};
|
|
71
|
+
const install = (dir, packageManager) => new Promise((done) => spawn(packageManager, ["install"], {
|
|
72
|
+
cwd: dir,
|
|
73
|
+
stdio: "inherit",
|
|
74
|
+
shell: platform === "win32",
|
|
75
|
+
})
|
|
76
|
+
.on("close", (code) => done(code === 0))
|
|
77
|
+
.on("error", () => done(false)));
|
|
78
|
+
export const initCommand = async (argv) => {
|
|
79
|
+
const dir = resolve(cwd(), argv.directory ?? ".");
|
|
80
|
+
// The one that ran the CLI, like npx or yarn dlx
|
|
81
|
+
const packageManager = env.npm_config_user_agent?.split("/")[0] || "npm";
|
|
82
|
+
try {
|
|
83
|
+
const { id, name } = await initApp(dir, argv);
|
|
84
|
+
const path = relative(cwd(), dir);
|
|
85
|
+
console.log(`✨ Created ${name} (${id}) in ${dir}\n`);
|
|
86
|
+
const installed = argv.install && (await install(dir, packageManager));
|
|
87
|
+
if (argv.install && !installed) {
|
|
88
|
+
console.log(`\n⚠️ Installing the dependencies failed\n`);
|
|
89
|
+
}
|
|
90
|
+
console.log(`Next steps:`);
|
|
91
|
+
if (path)
|
|
92
|
+
console.log(` cd ${path}`);
|
|
93
|
+
if (!installed)
|
|
94
|
+
console.log(` ${packageManager} install`);
|
|
95
|
+
console.log(` ${packageManager} run validate`);
|
|
96
|
+
console.log(` EXAMPLARY_API_KEY=... ${packageManager} run publish\n`);
|
|
97
|
+
console.log(`The app's ID can't be changed once it's published, so set it in app.ts first if needed.`);
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
console.error(`🚫 ${e.message}`);
|
|
101
|
+
exit(1);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const packageCommand: () => Promise<void>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { cwd } from "process";
|
|
4
|
+
import { buildCurrentApp } from "../lib/build-current-app.js";
|
|
5
|
+
const PACKAGE_DIR = join(cwd(), ".bundled");
|
|
6
|
+
const localUpload = async (fileName, contents) => {
|
|
7
|
+
await writeFile(join(PACKAGE_DIR, fileName), contents);
|
|
8
|
+
return `https://bundled.invalid/${fileName}`;
|
|
9
|
+
};
|
|
10
|
+
export const packageCommand = async () => {
|
|
11
|
+
await rm(PACKAGE_DIR, { recursive: true, force: true });
|
|
12
|
+
await mkdir(PACKAGE_DIR);
|
|
13
|
+
const manifest = await buildCurrentApp(localUpload);
|
|
14
|
+
await writeFile(join(PACKAGE_DIR, "app.json"), JSON.stringify(manifest, null, 2));
|
|
15
|
+
console.log(`\n📦 Packaged app in ${PACKAGE_DIR}`);
|
|
16
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { exit } from "process";
|
|
2
|
+
import { setTimeout } from "timers/promises";
|
|
3
|
+
import { API_HOST, API_KEY, setApiHost, setApiKey, uploadFile, } from "../lib/api.js";
|
|
4
|
+
import { buildCurrentApp, dryUpload } from "../lib/build-current-app.js";
|
|
5
|
+
const appUpload = (fileName, contents, contentType) => uploadFile(fileName, contents, contentType, "app");
|
|
6
|
+
const publishedContentSha256 = async (id) => {
|
|
7
|
+
const res = await fetch(`${API_HOST}/apps/${id}`, {
|
|
8
|
+
headers: { Authorization: `Bearer ${API_KEY}` },
|
|
9
|
+
});
|
|
10
|
+
return res.ok ? (await res.json()).contentSha256 : undefined;
|
|
11
|
+
};
|
|
12
|
+
// Deploys can upload the default apps before the API that accepts them is live
|
|
13
|
+
const PUBLISH_RETRIES = 20;
|
|
14
|
+
const PUBLISH_RETRY_DELAY_MS = 20000;
|
|
15
|
+
const publish = async (manifest, force, attempt = 0) => {
|
|
16
|
+
const res = await fetch(`${API_HOST}/apps${force ? "?force=true" : ""}`, {
|
|
17
|
+
headers: {
|
|
18
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
},
|
|
21
|
+
method: "POST",
|
|
22
|
+
body: JSON.stringify(manifest),
|
|
23
|
+
});
|
|
24
|
+
if (res.status !== 404 || attempt === PUBLISH_RETRIES)
|
|
25
|
+
return res;
|
|
26
|
+
console.log(`• The API doesn't accept apps yet, trying again in 20 seconds (${attempt + 1}/${PUBLISH_RETRIES})`);
|
|
27
|
+
await setTimeout(PUBLISH_RETRY_DELAY_MS);
|
|
28
|
+
return publish(manifest, force, attempt + 1);
|
|
29
|
+
};
|
|
30
|
+
export const publishCommand = async (argv) => {
|
|
31
|
+
setApiHost(argv.host);
|
|
32
|
+
setApiKey(argv.key);
|
|
33
|
+
// Built without uploading first, to skip uploading every file of an app
|
|
34
|
+
// that hasn't changed, like the default apps on every deploy
|
|
35
|
+
const { id, contentSha256 } = await buildCurrentApp(dryUpload, true);
|
|
36
|
+
if (!argv.force && contentSha256 === (await publishedContentSha256(id))) {
|
|
37
|
+
console.log(`• Nothing changed since the published version of ${id}\n`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const manifest = await buildCurrentApp(appUpload);
|
|
41
|
+
console.log(`\nPublishing app: ${manifest.id}...`);
|
|
42
|
+
const res = await publish(manifest, !!argv.force);
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
console.error(`🚫 Publishing app failed`);
|
|
45
|
+
console.log(await res.text());
|
|
46
|
+
exit(1);
|
|
47
|
+
}
|
|
48
|
+
const { versionStatus, versionNumber, unchanged } = await res.json();
|
|
49
|
+
if (unchanged) {
|
|
50
|
+
console.log(`• Nothing changed since version ${versionNumber} (${versionStatus})\n`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
console.log(versionStatus === "pending"
|
|
54
|
+
? `• Submitted, this version is used once Examplary has reviewed it\n`
|
|
55
|
+
: `• Published app\n`);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { cwd, exit } from "process";
|
|
2
|
+
import { API_HOST, API_KEY, setApiHost, setApiKey } from "../lib/api.js";
|
|
3
|
+
import { findAppFile, loadAppDefinition } from "../lib/build-app.js";
|
|
4
|
+
export const setSecretCommand = async (argv) => {
|
|
5
|
+
setApiHost(argv.host);
|
|
6
|
+
setApiKey(argv.key);
|
|
7
|
+
let appId;
|
|
8
|
+
try {
|
|
9
|
+
({ id: appId } = await loadAppDefinition(await findAppFile(cwd())));
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
console.error(`🚫 ${e.message}`);
|
|
13
|
+
exit(1);
|
|
14
|
+
}
|
|
15
|
+
const res = await fetch(`${API_HOST}/apps/${appId}/secrets/${argv.name}`, {
|
|
16
|
+
headers: {
|
|
17
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
18
|
+
"Content-Type": "application/json",
|
|
19
|
+
},
|
|
20
|
+
method: "PUT",
|
|
21
|
+
body: JSON.stringify({ value: argv.value }),
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
console.error(`🚫 Setting secret failed`);
|
|
25
|
+
console.log(await res.text());
|
|
26
|
+
exit(1);
|
|
27
|
+
}
|
|
28
|
+
console.log(`🔑 Set secret ${argv.name} for ${appId}`);
|
|
29
|
+
};
|
|
@@ -1,23 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
import { cwd, exit } from "process";
|
|
3
|
-
import { readMetaFile } from "../lib/read-meta.js";
|
|
4
|
-
import { isValid, printValidationResult, validateDefinition, } from "../lib/validate.js";
|
|
1
|
+
import { buildCurrentApp, dryUpload } from "../lib/build-current-app.js";
|
|
5
2
|
export const validateCommand = async () => {
|
|
6
|
-
|
|
7
|
-
let definition = {};
|
|
8
|
-
try {
|
|
9
|
-
({ definition } = await readMetaFile());
|
|
10
|
-
}
|
|
11
|
-
catch (e) {
|
|
12
|
-
console.error(`🚫 ${e.message}`);
|
|
13
|
-
exit(1);
|
|
14
|
-
}
|
|
15
|
-
console.log(`Validating question type: ${definition.id}...`);
|
|
16
|
-
const result = await validateDefinition(definition, dir);
|
|
17
|
-
printValidationResult(result);
|
|
18
|
-
if (!isValid(result)) {
|
|
19
|
-
console.error(`\n🚫 Validation failed`);
|
|
20
|
-
exit(1);
|
|
21
|
-
}
|
|
3
|
+
await buildCurrentApp(dryUpload);
|
|
22
4
|
console.log(`\n✅ Validation passed`);
|
|
23
5
|
};
|
package/dist/index.js
CHANGED
|
@@ -1,39 +1,48 @@
|
|
|
1
1
|
#! /usr/bin/env node
|
|
2
2
|
import yargs from "yargs";
|
|
3
3
|
import { hideBin } from "yargs/helpers";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { initCommand } from "./commands/init.js";
|
|
5
|
+
import { packageCommand } from "./commands/package.js";
|
|
6
|
+
import { publishCommand } from "./commands/publish.js";
|
|
7
|
+
import { setSecretCommand } from "./commands/set-secret.js";
|
|
6
8
|
import { validateCommand } from "./commands/validate.js";
|
|
9
|
+
const withApiOptions = (yargs) => yargs
|
|
10
|
+
.option("key", {
|
|
11
|
+
type: "string",
|
|
12
|
+
description: "API key for authentication (uses EXAMPLARY_API_KEY env var if not provided)",
|
|
13
|
+
default: process.env.EXAMPLARY_API_KEY || "",
|
|
14
|
+
})
|
|
15
|
+
.option("host", {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "API host to connect to",
|
|
18
|
+
default: "https://api.examplary.ai",
|
|
19
|
+
});
|
|
7
20
|
yargs()
|
|
8
21
|
.usage("examplary <cmd> [args]")
|
|
9
|
-
.command("
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
type: "string",
|
|
32
|
-
description: "API host to upload to",
|
|
33
|
-
default: "https://api.examplary.ai",
|
|
34
|
-
});
|
|
35
|
-
}, (args) => previewCommand({ host: args.host, key: args.key }))
|
|
22
|
+
.command("init [directory]", "Create a new app, in the given or the current directory", (yargs) => withApiOptions(yargs)
|
|
23
|
+
.positional("directory", { type: "string" })
|
|
24
|
+
.option("id", {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "The app's ID, like com.acme.gradebook",
|
|
27
|
+
})
|
|
28
|
+
.option("name", { type: "string", description: "The app's name" })
|
|
29
|
+
.option("install", {
|
|
30
|
+
type: "boolean",
|
|
31
|
+
default: true,
|
|
32
|
+
description: "Install the dependencies (--no-install to skip)",
|
|
33
|
+
}), (args) => initCommand(args))
|
|
34
|
+
.command("publish", "Bundle, upload and publish the app in this directory", (yargs) => withApiOptions(yargs).option("force", {
|
|
35
|
+
type: "boolean",
|
|
36
|
+
default: false,
|
|
37
|
+
description: "Publish a new version even when nothing changed",
|
|
38
|
+
}), (args) => publishCommand({ host: args.host, key: args.key, force: args.force }))
|
|
39
|
+
.command("validate", "Bundle and validate the app without publishing", (yargs) => yargs, () => validateCommand())
|
|
40
|
+
.command("package", "Bundle the app into a local .bundled directory, for debugging", (yargs) => yargs, () => packageCommand())
|
|
41
|
+
.command("set-secret <name> <value>", "Set a secret of the app, available to its hooks", (yargs) => withApiOptions(yargs)
|
|
42
|
+
.positional("name", { type: "string", demandOption: true })
|
|
43
|
+
.positional("value", { type: "string", demandOption: true }), (args) => setSecretCommand(args))
|
|
36
44
|
.help()
|
|
45
|
+
.strict()
|
|
37
46
|
.recommendCommands()
|
|
38
47
|
.demandCommand()
|
|
39
48
|
.parse(hideBin(process.argv));
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ export declare let API_HOST: string;
|
|
|
2
2
|
export declare let API_KEY: string;
|
|
3
3
|
export declare const setApiKey: (key: string) => void;
|
|
4
4
|
export declare const setApiHost: (host: string) => void;
|
|
5
|
-
export declare const uploadFile: (fileName: string, fileContents: any, contentType: string) => Promise<any>;
|
|
5
|
+
export declare const uploadFile: (fileName: string, fileContents: any, contentType: string, type?: string) => Promise<any>;
|
package/dist/lib/api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { basename } from "path";
|
|
2
2
|
import { exit } from "process";
|
|
3
|
+
import { setTimeout } from "timers/promises";
|
|
3
4
|
// These get overwritten by the CLI options
|
|
4
5
|
export let API_HOST = "https://api.examplary.ai";
|
|
5
6
|
export let API_KEY = process.env.EXAMPLARY_API_KEY || "";
|
|
@@ -13,17 +14,27 @@ export const setApiHost = (host) => {
|
|
|
13
14
|
return;
|
|
14
15
|
API_HOST = host;
|
|
15
16
|
};
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
params.append("type", "question-type");
|
|
21
|
-
// Request the upload URL
|
|
17
|
+
// An app uploads every file it consists of, which can add up across apps
|
|
18
|
+
const RATE_LIMIT_RETRIES = 4;
|
|
19
|
+
const RATE_LIMIT_DELAY_MS = 30000;
|
|
20
|
+
const requestUploadUrl = async (params, attempt = 0) => {
|
|
22
21
|
const res = await fetch(`${API_HOST}/media/upload?${params.toString()}`, {
|
|
23
22
|
headers: {
|
|
24
23
|
Authorization: `Bearer ${API_KEY}`,
|
|
25
24
|
},
|
|
26
25
|
});
|
|
26
|
+
if (res.status !== 429 || attempt === RATE_LIMIT_RETRIES)
|
|
27
|
+
return res;
|
|
28
|
+
console.log(`• Uploading too fast, trying again in 30 seconds`);
|
|
29
|
+
await setTimeout(RATE_LIMIT_DELAY_MS);
|
|
30
|
+
return requestUploadUrl(params, attempt + 1);
|
|
31
|
+
};
|
|
32
|
+
export const uploadFile = async (fileName, fileContents, contentType, type = "question-type") => {
|
|
33
|
+
const params = new URLSearchParams();
|
|
34
|
+
params.append("filename", basename(fileName));
|
|
35
|
+
params.append("contentType", contentType);
|
|
36
|
+
params.append("type", type);
|
|
37
|
+
const res = await requestUploadUrl(params);
|
|
27
38
|
if (!res.ok) {
|
|
28
39
|
const errorText = await res.text();
|
|
29
40
|
console.error(`🚫 Upload failed: ${errorText}`);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type Uploader = (fileName: string, contents: string | Buffer, contentType: string) => Promise<string>;
|
|
2
|
+
export declare const findAppFile: (dir: string) => Promise<string>;
|
|
3
|
+
/** Evaluates the app module in Node, returning the definition `defineApp()` built. */
|
|
4
|
+
export declare const loadAppDefinition: (file: string) => Promise<Record<string, any>>;
|
|
5
|
+
/**
|
|
6
|
+
* Bundles the app for the sandbox, which only offers standard JavaScript and
|
|
7
|
+
* `fetch`: a script defining `examplaryApp`, whose default export runs a hook.
|
|
8
|
+
*/
|
|
9
|
+
export declare const buildServerBundle: (file: string) => Promise<{
|
|
10
|
+
js: string;
|
|
11
|
+
sourcemap: string;
|
|
12
|
+
sha256: string;
|
|
13
|
+
}>;
|
|
14
|
+
/** Like `uploadPath`, for a path or an object of paths by language. */
|
|
15
|
+
export declare const uploadTranslatablePath: (dir: string, value: string | Record<string, string> | undefined, upload: Uploader) => Promise<any>;
|
|
16
|
+
/**
|
|
17
|
+
* Turns the app in `file` into the manifest `POST /apps` accepts: files
|
|
18
|
+
* uploaded, components and hooks bundled, and hooks listed by ID. Throws when
|
|
19
|
+
* the result isn't a valid app.
|
|
20
|
+
*/
|
|
21
|
+
export declare const buildAppManifest: (file: string, definition: Record<string, any>, uploadFile: Uploader) => Promise<Record<string, any>>;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import { access, mkdir, readFile } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { basename, dirname, extname, join, resolve } from "path";
|
|
5
|
+
import { pathToFileURL } from "url";
|
|
6
|
+
import { AppSchema } from "@examplary/schemas";
|
|
7
|
+
import { build } from "esbuild";
|
|
8
|
+
import { buildQuestionType } from "./build-question-type.js";
|
|
9
|
+
import { buildComponent, splitInlineSourcemap } from "./bundle.js";
|
|
10
|
+
import { buildStyles } from "./styles.js";
|
|
11
|
+
const CONTENT_TYPES = {
|
|
12
|
+
".svg": "image/svg+xml",
|
|
13
|
+
".png": "image/png",
|
|
14
|
+
".jpg": "image/jpeg",
|
|
15
|
+
".jpeg": "image/jpeg",
|
|
16
|
+
".webp": "image/webp",
|
|
17
|
+
".gif": "image/gif",
|
|
18
|
+
".md": "text/markdown",
|
|
19
|
+
".json": "application/json",
|
|
20
|
+
};
|
|
21
|
+
const APP_FILES = ["app.ts", "app.js", "app.mjs"];
|
|
22
|
+
const sha256 = (contents) => createHash("sha256").update(contents).digest("hex");
|
|
23
|
+
export const findAppFile = async (dir) => {
|
|
24
|
+
for (const name of APP_FILES) {
|
|
25
|
+
const file = resolve(dir, name);
|
|
26
|
+
try {
|
|
27
|
+
await access(file);
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`No ${APP_FILES.join(", ")} found in ${dir}`);
|
|
35
|
+
};
|
|
36
|
+
/** Evaluates the app module in Node, returning the definition `defineApp()` built. */
|
|
37
|
+
export const loadAppDefinition = async (file) => {
|
|
38
|
+
const outdir = join(tmpdir(), "examplary-cli");
|
|
39
|
+
await mkdir(outdir, { recursive: true });
|
|
40
|
+
const outfile = join(outdir, `${sha256(file).slice(0, 12)}.mjs`);
|
|
41
|
+
await build({
|
|
42
|
+
entryPoints: [file],
|
|
43
|
+
bundle: true,
|
|
44
|
+
platform: "node",
|
|
45
|
+
format: "esm",
|
|
46
|
+
target: "node22",
|
|
47
|
+
outfile,
|
|
48
|
+
banner: {
|
|
49
|
+
js: `import { createRequire } from "module"; const require = createRequire(import.meta.url);`,
|
|
50
|
+
},
|
|
51
|
+
logLevel: "silent",
|
|
52
|
+
});
|
|
53
|
+
const module = await import(`${pathToFileURL(outfile).href}?t=${Date.now()}`);
|
|
54
|
+
if (typeof module.default?.toDefinition !== "function") {
|
|
55
|
+
throw new Error(`${file} has to default-export defineApp()`);
|
|
56
|
+
}
|
|
57
|
+
return module.default.toDefinition();
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Bundles the app for the sandbox, which only offers standard JavaScript and
|
|
61
|
+
* `fetch`: a script defining `examplaryApp`, whose default export runs a hook.
|
|
62
|
+
*/
|
|
63
|
+
export const buildServerBundle = async (file) => {
|
|
64
|
+
const result = await build({
|
|
65
|
+
stdin: {
|
|
66
|
+
contents: `import app from ${JSON.stringify(file)};
|
|
67
|
+
export default (input) => app.runHook(input);`,
|
|
68
|
+
resolveDir: dirname(file),
|
|
69
|
+
},
|
|
70
|
+
bundle: true,
|
|
71
|
+
platform: "neutral",
|
|
72
|
+
mainFields: ["module", "main"],
|
|
73
|
+
format: "iife",
|
|
74
|
+
globalName: "examplaryApp",
|
|
75
|
+
target: "es2020",
|
|
76
|
+
minify: true,
|
|
77
|
+
sourcemap: "inline",
|
|
78
|
+
write: false,
|
|
79
|
+
logLevel: "silent",
|
|
80
|
+
});
|
|
81
|
+
return splitInlineSourcemap(result.outputFiles[0].text);
|
|
82
|
+
};
|
|
83
|
+
/** Uploads a file the app refers to by a relative path; URLs pass through. */
|
|
84
|
+
const uploadPath = async (dir, path, upload) => {
|
|
85
|
+
if (/^https?:\/\//.test(path))
|
|
86
|
+
return path;
|
|
87
|
+
const contents = await readFile(resolve(dir, path));
|
|
88
|
+
const contentType = CONTENT_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
89
|
+
return upload(basename(path), contents, contentType);
|
|
90
|
+
};
|
|
91
|
+
/** Like `uploadPath`, for a path or an object of paths by language. */
|
|
92
|
+
export const uploadTranslatablePath = async (dir, value, upload) => {
|
|
93
|
+
if (value === undefined)
|
|
94
|
+
return undefined;
|
|
95
|
+
if (typeof value === "string")
|
|
96
|
+
return uploadPath(dir, value, upload);
|
|
97
|
+
const entries = await Promise.all(Object.entries(value).map(async ([language, path]) => [
|
|
98
|
+
language,
|
|
99
|
+
await uploadPath(dir, path, upload),
|
|
100
|
+
]));
|
|
101
|
+
return Object.fromEntries(entries);
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Uploads a bundle's source map, then the bundle pointing at it, for readable
|
|
105
|
+
* reviews and errors. Its digest pins the reviewed code: whatever runs it checks it.
|
|
106
|
+
*/
|
|
107
|
+
const uploadBundle = async (name, { js, sourcemap }, upload) => {
|
|
108
|
+
const sourcemapUrl = sourcemap && (await upload(`${name}.map`, sourcemap, "application/json"));
|
|
109
|
+
const contents = sourcemapUrl
|
|
110
|
+
? `${js}\n//# sourceMappingURL=${sourcemapUrl}`
|
|
111
|
+
: js;
|
|
112
|
+
return {
|
|
113
|
+
url: await upload(name, contents, "application/javascript"),
|
|
114
|
+
sha256: sha256(contents),
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
const uploadComponent = async (dir, { id, path, translations }, upload) => {
|
|
118
|
+
const bundle = await buildComponent(resolve(dir, path));
|
|
119
|
+
return {
|
|
120
|
+
id,
|
|
121
|
+
...(await uploadBundle(`${id}.js`, bundle, upload)),
|
|
122
|
+
translations,
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
/** The Tailwind classes the app's components use, which the dashboard loads with them. */
|
|
126
|
+
const uploadStyles = async (dir, upload) => {
|
|
127
|
+
const css = await buildStyles(dir);
|
|
128
|
+
return css ? upload("styles.css", css, "text/css") : undefined;
|
|
129
|
+
};
|
|
130
|
+
const uploadAsset = async (dir, { id, path }, upload) => ({
|
|
131
|
+
id,
|
|
132
|
+
url: await uploadPath(dir, path, upload),
|
|
133
|
+
sha256: sha256(await readFile(resolve(dir, path))),
|
|
134
|
+
});
|
|
135
|
+
/**
|
|
136
|
+
* Turns the app in `file` into the manifest `POST /apps` accepts: files
|
|
137
|
+
* uploaded, components and hooks bundled, and hooks listed by ID. Throws when
|
|
138
|
+
* the result isn't a valid app.
|
|
139
|
+
*/
|
|
140
|
+
export const buildAppManifest = async (file, definition, uploadFile) => {
|
|
141
|
+
const { hooks, components, assets, questionTypes, ...app } = definition;
|
|
142
|
+
const dir = dirname(file);
|
|
143
|
+
// What the app consists of, without the uploads' fresh URLs, so the API can
|
|
144
|
+
// tell an unchanged upload apart
|
|
145
|
+
const digests = [];
|
|
146
|
+
const upload = (fileName, contents, contentType) => {
|
|
147
|
+
const withoutUrls = typeof contents === "string"
|
|
148
|
+
? contents.replace(/\n\/\/# sourceMappingURL=\S+$/, "")
|
|
149
|
+
: contents;
|
|
150
|
+
digests.push(`${fileName}:${sha256(withoutUrls)}`);
|
|
151
|
+
return uploadFile(fileName, contents, contentType);
|
|
152
|
+
};
|
|
153
|
+
const server = hooks.length &&
|
|
154
|
+
(await uploadBundle("server.js", await buildServerBundle(file), upload));
|
|
155
|
+
const manifest = {
|
|
156
|
+
...app,
|
|
157
|
+
icon: await uploadTranslatablePath(dir, app.icon, upload),
|
|
158
|
+
readme: await uploadTranslatablePath(dir, app.readme, upload),
|
|
159
|
+
previewImage: await uploadTranslatablePath(dir, app.previewImage, upload),
|
|
160
|
+
hooks: hooks.map(({ id }) => ({ id })),
|
|
161
|
+
server: server ? server.url : undefined,
|
|
162
|
+
serverSha256: server ? server.sha256 : undefined,
|
|
163
|
+
components: await Promise.all(components.map((component) => uploadComponent(dir, component, upload))),
|
|
164
|
+
stylesheet: components.length ? await uploadStyles(dir, upload) : undefined,
|
|
165
|
+
assets: await Promise.all(assets.map((asset) => uploadAsset(dir, asset, upload))),
|
|
166
|
+
questionTypes: [],
|
|
167
|
+
};
|
|
168
|
+
// One after the other, as each bundles a few components and runs its test cases
|
|
169
|
+
for (const questionType of questionTypes ?? []) {
|
|
170
|
+
manifest.questionTypes.push(await buildQuestionType(questionType, dir, upload));
|
|
171
|
+
}
|
|
172
|
+
manifest.contentSha256 = sha256([JSON.stringify(definition), ...digests.sort()].join("\n"));
|
|
173
|
+
const parsed = AppSchema.safeParse(manifest);
|
|
174
|
+
if (!parsed.success) {
|
|
175
|
+
const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "app"}: ${issue.message}`);
|
|
176
|
+
throw new Error(`Invalid app definition:\n${issues.join("\n")}`);
|
|
177
|
+
}
|
|
178
|
+
return manifest;
|
|
179
|
+
};
|