@promte/app-upload 0.1.12 → 0.2.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 +25 -12
- package/bin/promte-app-upload.js +372 -23
- package/package.json +11 -3
- package/skill/SKILL.md +168 -0
package/README.md
CHANGED
|
@@ -1,25 +1,38 @@
|
|
|
1
1
|
# promte-app-upload
|
|
2
2
|
|
|
3
|
-
CLI for uploading Promte
|
|
3
|
+
CLI for uploading Promte apps. On servers with managed builds it uploads the project source, and Promte checks and builds it. Older servers get the built `dist` folder.
|
|
4
4
|
|
|
5
5
|
## Quick start
|
|
6
6
|
```
|
|
7
|
-
npx @promte/app-upload
|
|
7
|
+
npx @promte/app-upload login
|
|
8
|
+
npx @promte/app-upload config --host https://app.promte.dk --workspaceId <id>
|
|
8
9
|
npx @promte/app-upload push
|
|
9
10
|
```
|
|
10
11
|
|
|
11
12
|
## Commands
|
|
12
13
|
```
|
|
13
|
-
npx @promte/app-upload
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
-
|
|
14
|
+
npx @promte/app-upload login [--host ...] [--app-url ...] [--no-open]
|
|
15
|
+
- opens Promte in the browser; approve "CLI access" there
|
|
16
|
+
- the 30-day token is saved in ~/.config/promte/credentials.json (never in the project)
|
|
17
|
+
|
|
18
|
+
npx @promte/app-upload logout [--host ...]
|
|
19
|
+
|
|
20
|
+
npx @promte/app-upload config [--host ...] [--workspaceId ...] [--source ...] [--dist ...]
|
|
21
|
+
- saves host/workspaceId/source/dist in ./promte-app-upload.config.json (interactive without flags)
|
|
22
|
+
|
|
23
|
+
npx @promte/app-upload push [--host ...] [--workspaceId ...] [--source ...] [--dist ...] [--zip ...] [--token ...]
|
|
24
|
+
- asks the server for its upload mode
|
|
25
|
+
- source mode: zips the project (default .) without node_modules, dist, .env files and other files the server rejects
|
|
26
|
+
- built mode: zips ./dist (must contain index.html)
|
|
27
|
+
- prints the release status; releases under review are published after approval
|
|
28
|
+
|
|
29
|
+
npx @promte/app-upload status [--release <id>]
|
|
30
|
+
- shows the latest releases or one release
|
|
31
|
+
|
|
32
|
+
npx @promte/app-upload skill [--dir ...]
|
|
33
|
+
- writes the Promte app skill for coding agents to .claude/skills/promte-app/ and .agents/skills/promte-app/
|
|
21
34
|
```
|
|
22
35
|
|
|
23
36
|
Notes:
|
|
24
|
-
-
|
|
25
|
-
-
|
|
37
|
+
- Token order: `--token`, `PROMTE_TOKEN`, then the token saved by `login`.
|
|
38
|
+
- Only workspace admins can upload releases.
|
package/bin/promte-app-upload.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
4
7
|
import AdmZip from "adm-zip";
|
|
5
8
|
import undici from "undici";
|
|
6
9
|
import readline from "node:readline/promises";
|
|
@@ -11,20 +14,53 @@ const BlobCtor = globalThis.Blob || Blob;
|
|
|
11
14
|
const DEFAULT_HOST = "https://app.promte.dk";
|
|
12
15
|
const DEFAULT_UPLOAD_HOST = "https://app.promte.dk";
|
|
13
16
|
const DEFAULT_DIST = "./dist";
|
|
17
|
+
const DEFAULT_SOURCE = ".";
|
|
14
18
|
const CONFIG_PATH = path.resolve(process.cwd(), "promte-app-upload.config.json");
|
|
19
|
+
const CREDENTIALS_PATH = path.join(
|
|
20
|
+
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
|
|
21
|
+
"promte",
|
|
22
|
+
"credentials.json",
|
|
23
|
+
);
|
|
24
|
+
const SHARED_APP_HOST_API = "https://app.promte.dk/api";
|
|
25
|
+
const SKILL_PATH = fileURLToPath(new URL("../skill/SKILL.md", import.meta.url));
|
|
26
|
+
|
|
27
|
+
// Mirrors the server's source ZIP rules (self-hosting/app-governance/source-archive.ts):
|
|
28
|
+
// these are rejected there, so they are left out here.
|
|
29
|
+
const SOURCE_SKIP_DIRS = new Set([
|
|
30
|
+
"node_modules", "bower_components", ".git", ".hg", ".svn", ".promte-source",
|
|
31
|
+
".cache", ".yarn", ".npm", ".pnpm-store", ".turbo", ".vite", ".parcel-cache", ".next", ".nuxt", ".output",
|
|
32
|
+
"dist", "build", "out", "coverage", "__pycache__", ".pytest_cache", ".mypy_cache", ".venv", "venv",
|
|
33
|
+
".ssh", ".aws", ".azure", ".gcloud", ".kube", ".docker", "__macosx",
|
|
34
|
+
".claude", ".agents", ".codex", ".cursor", ".idea", ".vscode",
|
|
35
|
+
]);
|
|
36
|
+
const SOURCE_SKIP_FILE =
|
|
37
|
+
/^(?:\.env(?:\..*)?|\.npmrc|\.pypirc|\.netrc|\.ds_store|\.yarnrc(?:\.yml)?|\.secrets?|credentials(?:\.[^.]+)?|secrets?\.(?:json|ya?ml|toml)|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|promte-app-upload\.config\.json|yarn\.lock|pnpm-lock\.yaml|vite\.config\.[cm]?[jt]s)$|\.(?:pem|key|p12|pfx|keystore|jks|zip|tar|tgz|gz|7z|rar|map|tsbuildinfo|log|pyc|o|so|dll|exe|class)$/i;
|
|
38
|
+
const SOURCE_ZIP_MAX_BYTES = 20 * 1024 * 1024;
|
|
39
|
+
const SOURCE_MAX_FILES = 2000;
|
|
15
40
|
|
|
16
41
|
function usage() {
|
|
17
42
|
console.log(
|
|
18
43
|
[
|
|
19
44
|
"Usage:",
|
|
20
|
-
" promte-app-upload
|
|
21
|
-
" promte-app-upload
|
|
45
|
+
" promte-app-upload login [--host <url>] [--app-url <url>] [--no-open]",
|
|
46
|
+
" promte-app-upload logout [--host <url>]",
|
|
47
|
+
" promte-app-upload config [--host <url>] [--workspaceId <id>] [--source <dir>] [--dist <dir>]",
|
|
48
|
+
" promte-app-upload push [--host <url>] [--workspaceId <id>] [--source <dir>] [--dist <dir>] [--zip <file>] [--token <token>]",
|
|
49
|
+
" promte-app-upload status [--host <url>] [--workspaceId <id>] [--release <id>]",
|
|
50
|
+
" promte-app-upload skill [--dir <project dir>]",
|
|
51
|
+
"",
|
|
52
|
+
"push asks the server which upload mode it uses:",
|
|
53
|
+
" source: zips the project source (--source, default .) and the server checks and builds it",
|
|
54
|
+
" built: zips the built output (--dist, default ./dist) on servers without managed builds",
|
|
55
|
+
"",
|
|
56
|
+
"Token: --token, then PROMTE_TOKEN, then the token saved by `login`.",
|
|
22
57
|
"",
|
|
23
58
|
"Defaults:",
|
|
24
59
|
` host (backend URL): ${DEFAULT_HOST}`,
|
|
25
|
-
`
|
|
60
|
+
` source: ${DEFAULT_SOURCE}`,
|
|
26
61
|
` dist: ${DEFAULT_DIST}`,
|
|
27
62
|
` config: ${CONFIG_PATH}`,
|
|
63
|
+
` credentials: ${CREDENTIALS_PATH}`,
|
|
28
64
|
].join("\n"),
|
|
29
65
|
);
|
|
30
66
|
}
|
|
@@ -35,8 +71,13 @@ function parseArgs(argv) {
|
|
|
35
71
|
const arg = argv[i];
|
|
36
72
|
if (arg.startsWith("--")) {
|
|
37
73
|
const key = arg.slice(2);
|
|
38
|
-
|
|
39
|
-
|
|
74
|
+
const next = argv[i + 1];
|
|
75
|
+
if (next === undefined || next.startsWith("--")) {
|
|
76
|
+
args[key] = true;
|
|
77
|
+
} else {
|
|
78
|
+
args[key] = next;
|
|
79
|
+
i += 1;
|
|
80
|
+
}
|
|
40
81
|
} else {
|
|
41
82
|
args._.push(arg);
|
|
42
83
|
}
|
|
@@ -69,6 +110,7 @@ async function prompt(question, defaultValue = "") {
|
|
|
69
110
|
}
|
|
70
111
|
|
|
71
112
|
async function promptSecret(question) {
|
|
113
|
+
if (!process.stdin.isTTY) return "";
|
|
72
114
|
process.stdout.write(`${question}: `);
|
|
73
115
|
process.stdin.setRawMode(true);
|
|
74
116
|
process.stdin.resume();
|
|
@@ -103,16 +145,250 @@ function assert(condition, message) {
|
|
|
103
145
|
}
|
|
104
146
|
}
|
|
105
147
|
|
|
106
|
-
async function runConfig() {
|
|
148
|
+
async function runConfig(args) {
|
|
107
149
|
const current = loadConfig();
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
|
|
150
|
+
const interactive = process.stdin.isTTY && !args.workspaceId;
|
|
151
|
+
const ask = (question, key, fallback) =>
|
|
152
|
+
typeof args[key] === "string"
|
|
153
|
+
? args[key]
|
|
154
|
+
: interactive
|
|
155
|
+
? prompt(question, current[key] || fallback)
|
|
156
|
+
: current[key] || fallback;
|
|
157
|
+
const host = await ask("Backend host", "host", DEFAULT_HOST);
|
|
158
|
+
const workspaceId = await ask("Workspace ID", "workspaceId", "");
|
|
159
|
+
const source = await ask("Source path", "source", DEFAULT_SOURCE);
|
|
160
|
+
const dist = await ask("Dist path (servers without managed builds)", "dist", DEFAULT_DIST);
|
|
111
161
|
assert(workspaceId, "workspaceId is required");
|
|
112
|
-
saveConfig({ host, workspaceId, dist });
|
|
162
|
+
saveConfig({ host, workspaceId, source, dist });
|
|
113
163
|
console.log(`Saved config to ${CONFIG_PATH}`);
|
|
114
164
|
}
|
|
115
165
|
|
|
166
|
+
function hostKey(host) {
|
|
167
|
+
return new URL(host).origin;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function loadCredentials() {
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf8")) || {};
|
|
173
|
+
} catch {
|
|
174
|
+
return {};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function saveCredentials(next) {
|
|
179
|
+
fs.mkdirSync(path.dirname(CREDENTIALS_PATH), { recursive: true, mode: 0o700 });
|
|
180
|
+
fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
181
|
+
fs.chmodSync(CREDENTIALS_PATH, 0o600);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveHost(args) {
|
|
185
|
+
return args.host || loadConfig().host || DEFAULT_HOST;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function resolveToken(args, host) {
|
|
189
|
+
if (typeof args.token === "string") return args.token;
|
|
190
|
+
if (process.env.PROMTE_TOKEN) return process.env.PROMTE_TOKEN;
|
|
191
|
+
const saved = loadCredentials()[hostKey(host)];
|
|
192
|
+
if (saved?.token) return saved.token;
|
|
193
|
+
const token = await promptSecret("Promte token");
|
|
194
|
+
assert(
|
|
195
|
+
token,
|
|
196
|
+
`Not signed in to ${hostKey(host)}. Run \`promte-app-upload login\` first, or set PROMTE_TOKEN.`,
|
|
197
|
+
);
|
|
198
|
+
return token;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function openInBrowser(url) {
|
|
202
|
+
const command =
|
|
203
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
204
|
+
const commandArgs = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
205
|
+
try {
|
|
206
|
+
const child = spawn(command, commandArgs, { stdio: "ignore", detached: true });
|
|
207
|
+
child.on("error", () => {});
|
|
208
|
+
child.unref();
|
|
209
|
+
} catch {
|
|
210
|
+
// The printed link still works.
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function runLogin(args) {
|
|
215
|
+
const host = resolveHost(args);
|
|
216
|
+
const startRes = await undiciFetch(new URL("/api/extension-auth/start", host).toString(), {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: { "Content-Type": "application/json" },
|
|
219
|
+
body: JSON.stringify({
|
|
220
|
+
client: "cli",
|
|
221
|
+
...(typeof args["app-url"] === "string" ? { platformBaseUrl: args["app-url"] } : {}),
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
const started = await parseResponseBody(startRes);
|
|
225
|
+
if (!startRes.ok || !started.json?.flowId) {
|
|
226
|
+
console.error(`Could not start sign-in (${startRes.status}): ${started.text || "Unknown error"}`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
const { flowId, flowSecret, approveUrl, expiresAt } = started.json;
|
|
230
|
+
const interval = Math.max(1000, Number(started.json.pollIntervalMs) || 1500);
|
|
231
|
+
|
|
232
|
+
console.log("Open this link, sign in to Promte and approve CLI access:");
|
|
233
|
+
console.log(`\n ${approveUrl}\n`);
|
|
234
|
+
if (!args["no-open"]) openInBrowser(approveUrl);
|
|
235
|
+
console.log("Waiting for approval…");
|
|
236
|
+
|
|
237
|
+
const statusUrl = new URL("/api/extension-auth/status", host);
|
|
238
|
+
statusUrl.searchParams.set("flowId", flowId);
|
|
239
|
+
statusUrl.searchParams.set("flowSecret", flowSecret);
|
|
240
|
+
while (Date.now() < Number(expiresAt || Date.now() + 10 * 60 * 1000)) {
|
|
241
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
242
|
+
let statusRes;
|
|
243
|
+
try {
|
|
244
|
+
statusRes = await undiciFetch(statusUrl.toString());
|
|
245
|
+
} catch {
|
|
246
|
+
// Keep waiting through brief network or server restarts.
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const status = await parseResponseBody(statusRes);
|
|
250
|
+
const state = status.json?.state;
|
|
251
|
+
if (state === "approved" && status.json?.token) {
|
|
252
|
+
const credentials = loadCredentials();
|
|
253
|
+
credentials[hostKey(host)] = {
|
|
254
|
+
token: status.json.token,
|
|
255
|
+
email: status.json.email || "",
|
|
256
|
+
savedAt: new Date().toISOString(),
|
|
257
|
+
};
|
|
258
|
+
saveCredentials(credentials);
|
|
259
|
+
console.log(`Signed in${status.json.email ? ` as ${status.json.email}` : ""} ✅`);
|
|
260
|
+
console.log(`Token saved to ${CREDENTIALS_PATH} (valid for 30 days).`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (["denied", "expired", "consumed", "not_found"].includes(state) || (!statusRes.ok && statusRes.status !== 429)) {
|
|
264
|
+
const reason =
|
|
265
|
+
state === "denied"
|
|
266
|
+
? "the request was denied"
|
|
267
|
+
: state === "not_found" || state === "expired" || state === "consumed"
|
|
268
|
+
? "the request expired or was already used"
|
|
269
|
+
: status.text || statusRes.status;
|
|
270
|
+
console.error(`Sign-in did not complete: ${reason}. Run \`promte-app-upload login\` again.`);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
console.error("Sign-in expired. Run login again.");
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function runLogout(args) {
|
|
279
|
+
const host = resolveHost(args);
|
|
280
|
+
const credentials = loadCredentials();
|
|
281
|
+
delete credentials[hostKey(host)];
|
|
282
|
+
saveCredentials(credentials);
|
|
283
|
+
console.log(`Signed out of ${hostKey(host)}.`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function runSkill(args) {
|
|
287
|
+
const projectDir = path.resolve(process.cwd(), typeof args.dir === "string" ? args.dir : ".");
|
|
288
|
+
const skill = fs.readFileSync(SKILL_PATH, "utf8");
|
|
289
|
+
for (const base of [".claude", ".agents"]) {
|
|
290
|
+
const target = path.join(projectDir, base, "skills", "promte-app", "SKILL.md");
|
|
291
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
292
|
+
fs.writeFileSync(target, skill);
|
|
293
|
+
console.log(`Wrote ${path.relative(process.cwd(), target) || target}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function collectSourceFiles(root) {
|
|
298
|
+
const files = [];
|
|
299
|
+
const walk = (dir) => {
|
|
300
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
301
|
+
const abs = path.join(dir, entry.name);
|
|
302
|
+
if (entry.isDirectory()) {
|
|
303
|
+
if (!SOURCE_SKIP_DIRS.has(entry.name.toLowerCase())) walk(abs);
|
|
304
|
+
} else if (entry.isFile() && !SOURCE_SKIP_FILE.test(entry.name)) {
|
|
305
|
+
files.push(abs);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
walk(root);
|
|
310
|
+
return files;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function hasRuntimeDependencies(root) {
|
|
314
|
+
try {
|
|
315
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
316
|
+
return ["dependencies", "peerDependencies", "optionalDependencies"].some(
|
|
317
|
+
(field) => manifest?.[field] && Object.keys(manifest[field]).length > 0,
|
|
318
|
+
);
|
|
319
|
+
} catch {
|
|
320
|
+
// Keep the lockfile when package.json is missing or unreadable.
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function zipSource(sourcePath) {
|
|
326
|
+
const abs = path.resolve(process.cwd(), sourcePath);
|
|
327
|
+
assert(fs.existsSync(abs) && fs.statSync(abs).isDirectory(), `Source folder not found: ${abs}`);
|
|
328
|
+
let files = collectSourceFiles(abs);
|
|
329
|
+
// The server only reads package-lock.json to pin runtime dependencies. Without
|
|
330
|
+
// any, it adds nothing, and its registry URLs can trip a strict origin review.
|
|
331
|
+
if (!hasRuntimeDependencies(abs)) {
|
|
332
|
+
files = files.filter((file) => path.relative(abs, file) !== "package-lock.json");
|
|
333
|
+
}
|
|
334
|
+
assert(files.length > 0, `No source files found in ${abs}`);
|
|
335
|
+
assert(
|
|
336
|
+
files.length <= SOURCE_MAX_FILES,
|
|
337
|
+
`The source has ${files.length} files; the server accepts at most ${SOURCE_MAX_FILES}.`,
|
|
338
|
+
);
|
|
339
|
+
const zip = new AdmZip();
|
|
340
|
+
for (const file of files) {
|
|
341
|
+
const rel = path.relative(abs, file).split(path.sep).join("/");
|
|
342
|
+
zip.addFile(rel, fs.readFileSync(file));
|
|
343
|
+
}
|
|
344
|
+
const buffer = zip.toBuffer();
|
|
345
|
+
assert(
|
|
346
|
+
buffer.length <= SOURCE_ZIP_MAX_BYTES,
|
|
347
|
+
`The source ZIP is ${(buffer.length / 1024 / 1024).toFixed(1)} MiB; the server accepts at most 20 MiB.`,
|
|
348
|
+
);
|
|
349
|
+
return { buffer, files: files.length };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function getUploadMode(host, workspaceId, token) {
|
|
353
|
+
const url = new URL("/api/app-upload/capabilities", host);
|
|
354
|
+
url.searchParams.set("workspaceId", workspaceId);
|
|
355
|
+
const res = await undiciFetch(url.toString(), { headers: { Authorization: `Bearer ${token}` } });
|
|
356
|
+
// Older servers have no capabilities endpoint and only accept built ZIPs.
|
|
357
|
+
if (res.status === 404) return { uploadMode: "built", available: true };
|
|
358
|
+
const body = await parseResponseBody(res);
|
|
359
|
+
if (res.status === 401 || res.status === 403) {
|
|
360
|
+
console.error(
|
|
361
|
+
`Not allowed to upload to workspace ${workspaceId} (${res.status}). You must be a workspace admin with access to app releases. If your token expired, run \`promte-app-upload login\` again.`,
|
|
362
|
+
);
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
365
|
+
if (!res.ok || !body.json) {
|
|
366
|
+
console.error(`Could not read upload mode (${res.status}): ${body.text || "Unknown error"}`);
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
return body.json;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function runStatus(args) {
|
|
373
|
+
const config = loadConfig();
|
|
374
|
+
const host = resolveHost(args);
|
|
375
|
+
const workspaceId = args.workspaceId || config.workspaceId;
|
|
376
|
+
assert(workspaceId, "workspaceId is required (config or --workspaceId)");
|
|
377
|
+
const token = await resolveToken(args, host);
|
|
378
|
+
const url = new URL(
|
|
379
|
+
`/api/app-upload/releases${typeof args.release === "string" ? `/${encodeURIComponent(args.release)}` : ""}`,
|
|
380
|
+
host,
|
|
381
|
+
);
|
|
382
|
+
url.searchParams.set("workspaceId", workspaceId);
|
|
383
|
+
const res = await undiciFetch(url.toString(), { headers: { Authorization: `Bearer ${token}` } });
|
|
384
|
+
const body = await parseResponseBody(res);
|
|
385
|
+
if (!res.ok) {
|
|
386
|
+
console.error(`Could not read releases (${res.status}): ${body.text || "Unknown error"}`);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
console.log(JSON.stringify(body.json?.release ?? (body.json?.releases || []).slice(0, 5), null, 2));
|
|
390
|
+
}
|
|
391
|
+
|
|
116
392
|
function ensureDist(distPath) {
|
|
117
393
|
const abs = path.resolve(process.cwd(), distPath);
|
|
118
394
|
assert(fs.existsSync(abs), `Dist folder not found: ${abs}`);
|
|
@@ -184,11 +460,11 @@ async function parseResponseBody(res) {
|
|
|
184
460
|
|
|
185
461
|
async function runPush(args) {
|
|
186
462
|
const config = loadConfig();
|
|
187
|
-
const host = args
|
|
463
|
+
const host = resolveHost(args);
|
|
188
464
|
const workspaceId = args.workspaceId || config.workspaceId;
|
|
189
465
|
const dist = args.dist || config.dist || DEFAULT_DIST;
|
|
190
|
-
const
|
|
191
|
-
|
|
466
|
+
const sourcePath = args.source || config.source || DEFAULT_SOURCE;
|
|
467
|
+
const zipPath = typeof args.zip === "string" ? args.zip : null;
|
|
192
468
|
const requestedVersion = args.version ? Number(args.version) : null;
|
|
193
469
|
if (args.version) {
|
|
194
470
|
assert(
|
|
@@ -198,29 +474,40 @@ async function runPush(args) {
|
|
|
198
474
|
}
|
|
199
475
|
|
|
200
476
|
assert(workspaceId, "workspaceId is required (config or --workspaceId)");
|
|
201
|
-
const
|
|
477
|
+
const token = await resolveToken(args, host);
|
|
202
478
|
|
|
203
|
-
|
|
204
|
-
|
|
479
|
+
const capabilities = await getUploadMode(host, workspaceId, token);
|
|
480
|
+
if (!capabilities.available) {
|
|
481
|
+
console.error(capabilities.reason || "The server cannot build apps right now. Try again later.");
|
|
482
|
+
process.exit(1);
|
|
205
483
|
}
|
|
206
|
-
|
|
484
|
+
const mode = capabilities.uploadMode === "source" ? "source" : "built";
|
|
207
485
|
|
|
208
486
|
let zipBuffer;
|
|
209
|
-
let zipName
|
|
487
|
+
let zipName;
|
|
210
488
|
if (zipPath) {
|
|
211
|
-
|
|
489
|
+
if (mode === "built") ensureZipHasIndex(zipPath);
|
|
490
|
+
const abs = path.resolve(process.cwd(), zipPath);
|
|
491
|
+
assert(fs.existsSync(abs) && /\.zip$/i.test(abs), `Zip file not found: ${abs}`);
|
|
212
492
|
zipBuffer = fs.readFileSync(abs);
|
|
213
|
-
zipName =
|
|
493
|
+
zipName = path.basename(abs);
|
|
494
|
+
} else if (mode === "source") {
|
|
495
|
+
const zipped = zipSource(sourcePath);
|
|
496
|
+
zipBuffer = zipped.buffer;
|
|
497
|
+
zipName = "source.zip";
|
|
498
|
+
console.log(`Zipped ${zipped.files} source files (${(zipBuffer.length / 1024).toFixed(0)} KiB).`);
|
|
214
499
|
} else {
|
|
500
|
+
const absDist = ensureDist(dist);
|
|
215
501
|
const zip = new AdmZip();
|
|
216
502
|
zip.addLocalFolder(absDist, "");
|
|
217
503
|
zipBuffer = zip.toBuffer();
|
|
504
|
+
zipName = "dist.zip";
|
|
218
505
|
}
|
|
219
506
|
|
|
220
507
|
assert(BlobCtor, "Blob is not available in this Node runtime.");
|
|
221
508
|
const form = new FormData();
|
|
222
509
|
const blob = new BlobCtor([zipBuffer], { type: "application/zip" });
|
|
223
|
-
form.append("app", blob, zipName);
|
|
510
|
+
form.append(mode === "source" ? "source" : "app", blob, zipName);
|
|
224
511
|
|
|
225
512
|
const prepareUrl = new URL("/api/app-upload/prepare", host);
|
|
226
513
|
const prepareRes = await undiciFetch(prepareUrl.toString(), {
|
|
@@ -251,8 +538,10 @@ async function runPush(args) {
|
|
|
251
538
|
|
|
252
539
|
const uploadToken = prepareBody.json?.uploadToken;
|
|
253
540
|
const version = Number(prepareBody.json?.version);
|
|
541
|
+
const preparedHost = String(prepareBody.json?.uploadHost || "").trim();
|
|
542
|
+
// "same-server" means the backend that prepared the upload also receives it.
|
|
254
543
|
const uploadHost =
|
|
255
|
-
|
|
544
|
+
preparedHost === "same-server" ? host : preparedHost || DEFAULT_UPLOAD_HOST;
|
|
256
545
|
|
|
257
546
|
assert(uploadToken, "Prepare response is missing uploadToken");
|
|
258
547
|
assert(
|
|
@@ -260,6 +549,7 @@ async function runPush(args) {
|
|
|
260
549
|
"Prepare response has invalid version",
|
|
261
550
|
);
|
|
262
551
|
|
|
552
|
+
if (mode === "source") console.log(`Uploading version ${version}; the server checks and builds it…`);
|
|
263
553
|
const uploadUrl = new URL("/api/upload-app", uploadHost);
|
|
264
554
|
uploadUrl.searchParams.set("workspaceId", workspaceId);
|
|
265
555
|
uploadUrl.searchParams.set("version", String(version));
|
|
@@ -301,12 +591,51 @@ async function runPush(args) {
|
|
|
301
591
|
process.exit(1);
|
|
302
592
|
}
|
|
303
593
|
|
|
594
|
+
// Same as the web app: published built releases on other servers are also
|
|
595
|
+
// copied to the shared app host that serves *.apps.promte.dk.
|
|
596
|
+
const hostname = new URL(host).hostname;
|
|
597
|
+
if (
|
|
598
|
+
mode === "built" &&
|
|
599
|
+
typeof finalizeBody.json?.hostedCopyToken === "string" &&
|
|
600
|
+
hostname !== "localhost" &&
|
|
601
|
+
hostname !== "127.0.0.1"
|
|
602
|
+
) {
|
|
603
|
+
const copyUrl = new URL(`${SHARED_APP_HOST_API}/app-upload/hosted-copy`);
|
|
604
|
+
copyUrl.searchParams.set("workspaceId", workspaceId);
|
|
605
|
+
copyUrl.searchParams.set("version", String(version));
|
|
606
|
+
const copyForm = new FormData();
|
|
607
|
+
copyForm.append("app", new BlobCtor([zipBuffer], { type: "application/zip" }), zipName);
|
|
608
|
+
const copyRes = await undiciFetch(copyUrl.toString(), {
|
|
609
|
+
method: "POST",
|
|
610
|
+
headers: { Authorization: `Bearer ${finalizeBody.json.hostedCopyToken}` },
|
|
611
|
+
body: copyForm,
|
|
612
|
+
});
|
|
613
|
+
if (!copyRes.ok) {
|
|
614
|
+
const copyBody = await parseResponseBody(copyRes);
|
|
615
|
+
console.error(
|
|
616
|
+
`The release was published but could not be copied to the app host (${copyRes.status}): ${copyBody.text || "Unknown error"}. Push again to retry.`,
|
|
617
|
+
);
|
|
618
|
+
process.exit(1);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const status = String(finalizeBody.json?.status || uploadBody.json?.status || "reviewing");
|
|
304
623
|
console.log("Upload complete ✅");
|
|
305
624
|
console.log(`Workspace: ${workspaceId}`);
|
|
306
625
|
console.log(`Version: ${version}`);
|
|
626
|
+
console.log(`Mode: ${mode}`);
|
|
307
627
|
if (uploadBody.json?.filesWritten != null) {
|
|
308
628
|
console.log(`Files: ${uploadBody.json.filesWritten}`);
|
|
309
629
|
}
|
|
630
|
+
if (finalizeBody.json?.releaseId) console.log(`Release: ${finalizeBody.json.releaseId}`);
|
|
631
|
+
console.log(`Status: ${status}`);
|
|
632
|
+
if (status === "published") {
|
|
633
|
+
console.log("The release is published and active.");
|
|
634
|
+
} else {
|
|
635
|
+
console.log(
|
|
636
|
+
"The release is under review; the current app stays online until it is published. Check with `promte-app-upload status`.",
|
|
637
|
+
);
|
|
638
|
+
}
|
|
310
639
|
}
|
|
311
640
|
|
|
312
641
|
async function main() {
|
|
@@ -319,7 +648,27 @@ async function main() {
|
|
|
319
648
|
}
|
|
320
649
|
|
|
321
650
|
if (cmd === "config") {
|
|
322
|
-
await runConfig();
|
|
651
|
+
await runConfig(args);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (cmd === "login") {
|
|
656
|
+
await runLogin(args);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (cmd === "logout") {
|
|
661
|
+
runLogout(args);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (cmd === "status") {
|
|
666
|
+
await runStatus(args);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (cmd === "skill") {
|
|
671
|
+
runSkill(args);
|
|
323
672
|
return;
|
|
324
673
|
}
|
|
325
674
|
|
package/package.json
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promte/app-upload",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "CLI to upload Promte app bundles",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"promte-app-upload": "bin/promte-app-upload.js"
|
|
8
8
|
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"skill",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test test/*.test.mjs"
|
|
16
|
+
},
|
|
9
17
|
"publishConfig": {
|
|
10
18
|
"access": "public"
|
|
11
19
|
},
|
|
12
20
|
"engines": {
|
|
13
|
-
"node": ">=18.
|
|
21
|
+
"node": ">=20.18.1"
|
|
14
22
|
},
|
|
15
23
|
"dependencies": {
|
|
16
|
-
"adm-zip": "^0.
|
|
24
|
+
"adm-zip": "^0.6.1",
|
|
17
25
|
"undici": "^7.8.0"
|
|
18
26
|
}
|
|
19
27
|
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: promte-app
|
|
3
|
+
description: Build, test locally and publish Promte apps (React apps embedded in a Promte workspace) with @promte/app-library and the @promte/app-upload CLI. Use when creating or changing a Promte app, calling Promte models/data/knowledge/functions from an app, or uploading a new app release to Promte.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Promte apps
|
|
7
|
+
|
|
8
|
+
A Promte app is a small React single-page app that runs in an iframe inside a Promte workspace. It talks to Promte only through `@promte/app-library`, which gets a short-lived app token from the parent page. You write the source; **Promte checks and builds it on the server** when you upload it.
|
|
9
|
+
|
|
10
|
+
Two npm packages:
|
|
11
|
+
|
|
12
|
+
- `@promte/app-library` – React provider, hooks and a client for models, data, files, knowledge, functions and more.
|
|
13
|
+
- `@promte/app-upload` – CLI that signs in, uploads the source and reports the release status.
|
|
14
|
+
|
|
15
|
+
## Workflow
|
|
16
|
+
|
|
17
|
+
1. Create or update the project so it follows the **build contract** below.
|
|
18
|
+
2. Run it locally with Vite and preview it inside Promte (see *Local development*).
|
|
19
|
+
3. Sign in once: `npx @promte/app-upload login`. The user approves in the browser; the token is stored in `~/.config/promte/credentials.json` for 30 days. Never ask the user to paste a token into chat.
|
|
20
|
+
4. Save the target once: `npx @promte/app-upload config --host <backend URL> --workspaceId <id>` (writes `promte-app-upload.config.json`; it holds no secrets).
|
|
21
|
+
5. Upload: `npx @promte/app-upload push`. Read the output: build errors come back as plain messages, fix them and push again.
|
|
22
|
+
6. If the status is not `published`, the release is under review. Check with `npx @promte/app-upload status`. The current app stays online until a release is published.
|
|
23
|
+
|
|
24
|
+
`login` and `push` need a person once: login needs browser approval, and only workspace admins may upload. For CI or a sandbox without a browser, set `PROMTE_TOKEN` instead.
|
|
25
|
+
|
|
26
|
+
Default host is `https://app.promte.dk`. Customers on their own Promte server use that server's URL (the address they open Promte on). For a local backend use e.g. `--host http://localhost:3000` and, when the frontend runs on another port, `login --app-url http://localhost:5173`.
|
|
27
|
+
|
|
28
|
+
## Build contract (source uploads)
|
|
29
|
+
|
|
30
|
+
The server builds with a **fixed React + Vite configuration** (Node 22, ES2022, `base: './'`). Anything that needs a custom build step is rejected. Keep the project plain:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
index.html # optional; otherwise main.jsx/tsx/js/ts at root or in src/ is used
|
|
34
|
+
src/main.jsx # wraps the app in <PromteAppProvider>
|
|
35
|
+
src/App.jsx
|
|
36
|
+
src/*.css # plain CSS (no Tailwind/PostCSS config)
|
|
37
|
+
public/ # static files copied as-is
|
|
38
|
+
functions/<name>/ # optional backend functions (private, never in the browser bundle)
|
|
39
|
+
package.json
|
|
40
|
+
package-lock.json # required if you use ^ or ~ ranges
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Rules the server enforces:
|
|
44
|
+
|
|
45
|
+
- **No build config files:** `vite.config.*`, `postcss.config.*`, `tailwind.config.*`, `babel.config.*`, `webpack.config.*`, `rollup.config.*`, `.babelrc`, `.postcssrc`. The CLI leaves `vite.config.*` out of the upload so you can keep one for local dev only.
|
|
46
|
+
- **tsconfig/jsconfig:** no `extends`, `paths`, `baseUrl`, `plugins`, `jsxImportSource` or decorators.
|
|
47
|
+
- **package.json:** no `workspaces`, `imports`, `exports`, `overrides`, `resolutions`, `browser`, `babel`, `postcss`. No `preinstall`/`install`/`postinstall`/`prepare`/`prebuild`/`postbuild` scripts. `scripts.build` may only be `vite build` or `tsc && vite build` (it is not run anyway).
|
|
48
|
+
- **Dependencies:** only `dependencies`/`peerDependencies`/`optionalDependencies` are installed (devDependencies are ignored, so put Vite and types there). Versions must be exact (`1.2.3`), or `^`/`~` pinned by `package-lock.json` (npm only; yarn/pnpm lockfiles are ignored). No tags, URLs, git refs or `npm:` aliases. At most 20 extra packages. Packages with native code or install scripts fail. A workspace may also require admin approval for each npm package ("npm package approval required").
|
|
49
|
+
- **Supplied by Promte:** `react` and `react-dom` 19.2.8, `@promte/app-library` (whatever version that server has) and `html2canvas` 1.4.1. Put them in `devDependencies` so they are installed locally but not checked by the server. If you list them in `dependencies`, the version must match the server's exactly or by `^`/`~`, and the library version differs between servers. Never declare `vite`, `esbuild` or `scheduler` as dependencies.
|
|
50
|
+
- **Source ZIP limits:** 20 MiB zipped, 50 MiB extracted, 10 MiB per file, 2,000 files. Text files must be UTF-8. Private keys, `.env*`, `.npmrc`, credentials, archives, source maps, `node_modules`, `dist` and `build` are rejected; the CLI skips them for you.
|
|
51
|
+
- **Build output:** must contain `index.html`, at most 256 files and 6 MiB. No lint, typecheck or tests are run, so run them yourself before pushing.
|
|
52
|
+
- Code under `functions/`, `backend/`, manifests and `.env` are private: importing them from browser code fails the build.
|
|
53
|
+
- Build time is limited to roughly two minutes.
|
|
54
|
+
|
|
55
|
+
### Minimal project
|
|
56
|
+
|
|
57
|
+
`package.json`:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"name": "my-promte-app",
|
|
62
|
+
"private": true,
|
|
63
|
+
"type": "module",
|
|
64
|
+
"scripts": { "dev": "vite", "build": "vite build" },
|
|
65
|
+
"dependencies": {},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@promte/app-library": "^0.1.6",
|
|
68
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
69
|
+
"react": "19.2.8",
|
|
70
|
+
"react-dom": "19.2.8",
|
|
71
|
+
"vite": "^5.4.10"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`vite.config.js` (local dev only; never uploaded):
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
import { defineConfig } from "vite";
|
|
80
|
+
import react from "@vitejs/plugin-react";
|
|
81
|
+
export default defineConfig({ plugins: [react()], base: "./" });
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`src/main.jsx`:
|
|
85
|
+
|
|
86
|
+
```jsx
|
|
87
|
+
import React from "react";
|
|
88
|
+
import { createRoot } from "react-dom/client";
|
|
89
|
+
import { PromteAppProvider } from "@promte/app-library";
|
|
90
|
+
import App from "./App.jsx";
|
|
91
|
+
import "./style.css";
|
|
92
|
+
|
|
93
|
+
createRoot(document.getElementById("root")).render(
|
|
94
|
+
<React.StrictMode>
|
|
95
|
+
<PromteAppProvider>
|
|
96
|
+
<App />
|
|
97
|
+
</PromteAppProvider>
|
|
98
|
+
</React.StrictMode>,
|
|
99
|
+
);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Install with `npm install --legacy-peer-deps` (the library still declares a React 18 peer range) and commit `package-lock.json`.
|
|
103
|
+
|
|
104
|
+
## Using @promte/app-library
|
|
105
|
+
|
|
106
|
+
Everything must be inside `<PromteAppProvider>`. Wait for `ready` before calling the client; outside Promte the provider never becomes ready.
|
|
107
|
+
|
|
108
|
+
```jsx
|
|
109
|
+
import { usePromte, usePromteClient, usePromteUser, usePromteDarkMode, usePromteModels } from "@promte/app-library";
|
|
110
|
+
|
|
111
|
+
const { ready, state, error } = usePromte();
|
|
112
|
+
const client = usePromteClient();
|
|
113
|
+
const user = usePromteUser(); // { email, isAdmin, role, workspaceId } | null
|
|
114
|
+
const dark = usePromteDarkMode(); // follow Promte's theme
|
|
115
|
+
const { models } = usePromteModels({ type: "llm", configuredOnly: true });
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Client API (`client.…`):
|
|
119
|
+
|
|
120
|
+
- **Models:** `models({ type, configuredOnly })` → `{ id, label, type, configured }[]`. Types: `llm`, `embedding`, `image`, `stt`, `tts`. Always pick a model from this list; never hard-code model IDs.
|
|
121
|
+
- **Chat:** `chatText({ model, messages })` → string; `chatCompletions({ model, messages, temperature, reasoningEffort })` → OpenAI-style response. Messages: `{ role: "system"|"user"|"assistant", content }`.
|
|
122
|
+
- **Embeddings:** `embeddings.create({ model, input })` → `{ data: [{ embedding }] }`.
|
|
123
|
+
- **Data (JSON tree):** `data(scope).ref("a/b")` with `get()`, `set(v)`, `update({ "x/y": v })`, `remove()`, `push(v)`. Scope `"private"` (default, per user) or `"public"` (whole workspace; only admins can write). `get()` returns `null` if missing.
|
|
124
|
+
- **Files** (newer servers): `files(scope)` with `upload(blob, { name })`, `list({ prefix })`, `download(id)`, `remove(id)`.
|
|
125
|
+
- **Knowledge search:** `knowledge.search({ query, workspaceId?, maxResults?, generateSearchQuery? })` → `{ results: [{ title, text, sourceUrl }] }`.
|
|
126
|
+
- **Web:** `web.scrapeUrl({ url })`.
|
|
127
|
+
- **Audio:** `audio.tts(...)`, `audio.stt(...)`, `audio.voices()`.
|
|
128
|
+
- **Guardrails:** `guardrails.check(value)` → `{ allowed, outcome: "allow"|"warn"|"block" }`.
|
|
129
|
+
- **Backend functions:** `functions.invoke(name, input)` → the handler's result.
|
|
130
|
+
|
|
131
|
+
The library's README on npm has the full reference and types.
|
|
132
|
+
|
|
133
|
+
Rules:
|
|
134
|
+
|
|
135
|
+
- Never put API keys, passwords or connection secrets in browser code. Credentials belong in admin-managed connections that backend functions use.
|
|
136
|
+
- Store app state with `client.data()` or `client.files()` so it follows the user across devices, not `localStorage`.
|
|
137
|
+
- Write user-facing text in the user's language. Promte apps are usually Danish; support both Danish and English when unsure.
|
|
138
|
+
|
|
139
|
+
## Backend functions (optional)
|
|
140
|
+
|
|
141
|
+
Each function is a folder `functions/<name>/` (lowercase letters, digits and hyphens, starting with a letter, max 64 characters, same as `name`) containing:
|
|
142
|
+
|
|
143
|
+
- `function.json`: `name`, `description`, `triggers` (`{ "callable": true, "tool": false }`, optional `"schedule": { "cron": "0 7 * * 1-5", "timezone": "Europe/Copenhagen" }`), `inputSchema`, `outputSchema`, `connections` (connection IDs set up by an admin), `capabilities` (e.g. `"data.read"`, `"data.write"`).
|
|
144
|
+
- `index.ts`: `export default async function (input, ctx) { … return json; }`.
|
|
145
|
+
- Optional `tests.json`: `[{ "name", "input", "expected" }]`.
|
|
146
|
+
|
|
147
|
+
Inside a handler you get `ctx.connections.request(id, { operation, params, body })`, `ctx.env.get(NAME)` and `ctx.data.get/set/update/delete(path, { scope })`. You may only import local `.ts`/`.js`/`.json` files under `functions/`; no npm or remote imports. Schemas use a small JSON Schema subset (`type`, `properties`, `required`, `additionalProperties`, `items`, `enum`, min/max) with no `$ref`, `pattern` or unions. Defaults: 30 s, 128 MiB, 64 KiB input and 256 KiB output. Functions go through governance review with the release, and schedules must be activated by an admin after publishing.
|
|
148
|
+
|
|
149
|
+
## Local development
|
|
150
|
+
|
|
151
|
+
1. `npm run dev` (Vite on `http://localhost:5173`).
|
|
152
|
+
2. In Promte, open the app's workspace → **Preview** → *Local development preview* (workspace admins only), and enter the local URL. Promte loads it in its iframe and hands it a token, so the library calls the real workspace.
|
|
153
|
+
3. If the user cannot open Promte, you can still check that the app builds with `npx vite build`, but anything that needs `ready` will not run.
|
|
154
|
+
|
|
155
|
+
## Troubleshooting
|
|
156
|
+
|
|
157
|
+
| Message | Fix |
|
|
158
|
+
| --- | --- |
|
|
159
|
+
| `Not signed in` / 401 | Run `npx @promte/app-upload login`. |
|
|
160
|
+
| 403 on upload | The user must be an admin of that workspace with access to app releases. |
|
|
161
|
+
| `Custom build configuration is not supported` | Remove the build config file named in the error (the CLI already leaves out `vite.config.*`). |
|
|
162
|
+
| `requires an exact npm version…` | Use an exact version, or keep `^`/`~` and commit `package-lock.json`. |
|
|
163
|
+
| `is supplied by the fixed Coding runtime` | Move react/react-dom/@promte/app-library to `devDependencies`. |
|
|
164
|
+
| `npm package approval required` | A workspace admin must approve that package and version. |
|
|
165
|
+
| `Upload a clean source ZIP…` | A forbidden file slipped in; check the paths in the message. |
|
|
166
|
+
| `The Azure source builder is unavailable` | Server-side problem; try again later or contact the Promte admin. |
|
|
167
|
+
| Status `blocked` / `rejected` | Governance review found a problem; see the release in Promte → Releases. |
|
|
168
|
+
| Blocked: `External origin … is not on the frontend allowlist` | The workspace only allows approved domains. Remove the URL from browser code, call it from a backend function, or ask an admin to allow it. (Older servers also flagged `package-lock.json`; the CLI leaves the lockfile out when there are no runtime `dependencies`.) |
|