lecodes-cli 2.2.11 → 2.2.13
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 +4 -2
- package/dist/index.js +1168 -870
- package/package.json +9 -9
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/player.js +2 -2
- package/runtime/web-lite/lite.js +9 -9
- package/src/cli/args.ts +10 -3
- package/src/cli/command.ts +7 -0
- package/src/cli/run.ts +2 -1
- package/src/commands/app/android.ts +7 -10
- package/src/commands/app/desktop.ts +3 -3
- package/src/commands/app/index.ts +10 -13
- package/src/commands/app/shared.ts +43 -17
- package/src/commands/app/templates/android.ts +1 -1
- package/src/commands/index.ts +3 -1
- package/src/commands/init/templates.ts +4 -3
- package/src/commands/publish.ts +38 -0
- package/src/commands/push.ts +28 -15
- package/src/commands/releases.ts +168 -0
- package/src/hosts/desktopScript.ts +3 -3
- package/src/platform/api.ts +114 -8
- package/src/plugin/generate.ts +9 -6
- package/src/plugin/permissions.ts +42 -10
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import { CliError, UsageError, bool, c, confirm, defineCommand, info, log, relativeTime, str, success, table, warn } from "../cli"
|
|
4
|
+
import { SchemaChangeError, listReleases, uploadReleaseBundle, useRelease } from "../platform/api"
|
|
5
|
+
import { diffLocal, hasChanges, scanProject } from "../project/localFiles"
|
|
6
|
+
import { runPush } from "./push"
|
|
7
|
+
import { linkedProject } from "./shared"
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* `lecodes releases` — the project's releases (docs/plans/publishing-plan.md): every publish is an
|
|
11
|
+
* immutable release with an auto-bumped version, one of them is CURRENT (what
|
|
12
|
+
* `https://<handle>.le.codes`, the viewers and the store run). `releases use <version>` makes
|
|
13
|
+
* another one current — a rollback, without a compile. `releases upload <app.js>` makes a release
|
|
14
|
+
* of a bundle compiled HERE — for an SDK the platform's build service no longer compiles with.
|
|
15
|
+
*
|
|
16
|
+
* What `publish`, `push --publish` and `releases use` share lives here too: the result line and
|
|
17
|
+
* the confirmation of a schema change that deletes production data.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** `1.2.4` vs `1.2.10`, numerically per part (the platform bumps the patch on every publish). */
|
|
21
|
+
export const compareVersions = (a: string, b: string): number => {
|
|
22
|
+
const pa = a.split("."), pb = b.split(".")
|
|
23
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
24
|
+
const d = (parseInt(pa[i] ?? "0", 10) || 0) - (parseInt(pb[i] ?? "0", 10) || 0)
|
|
25
|
+
if (d) return d
|
|
26
|
+
}
|
|
27
|
+
return 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const formatSize = (bytes: number): string =>
|
|
31
|
+
bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
32
|
+
|
|
33
|
+
/** The platform's page for a phone: open in the app, or the QR of itself on a desktop. It is the
|
|
34
|
+
* le.codes one whether or not the project has its web version on. */
|
|
35
|
+
export const qrUrl = (apiUrl: string, uuid: string): string => `${apiUrl}/qr/${uuid}`
|
|
36
|
+
|
|
37
|
+
/** `✓ Published 1.2.4 · https://nt0qnjn9.le.codes · 1.1 MB · SDK 2.2.11` and the QR page under it;
|
|
38
|
+
* a publish the platform answered `unchanged` (the head is the live release already, none made):
|
|
39
|
+
* `✓ Nothing changed — 1.2.4 is current · https://nt0qnjn9.le.codes`. */
|
|
40
|
+
export const reportRelease = (
|
|
41
|
+
release: { version: string, url: string, size?: number, sdk?: string, unchanged?: boolean },
|
|
42
|
+
qr: string,
|
|
43
|
+
verb: "Published" | "Rolled back to" | "Uploaded" = "Published",
|
|
44
|
+
): void => {
|
|
45
|
+
const parts = [release.unchanged ? `Nothing changed — ${release.version} is current` : `${verb} ${release.version}`, release.url.replace(/\/+$/, "")]
|
|
46
|
+
if (!release.unchanged) {
|
|
47
|
+
if (typeof release.size === "number") parts.push(formatSize(release.size))
|
|
48
|
+
if (release.sdk) parts.push(`SDK ${release.sdk}`)
|
|
49
|
+
}
|
|
50
|
+
success(parts.join(" · "))
|
|
51
|
+
info(` ${c.dim("QR")} ${qr}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A release call, sent first WITHOUT `allowDestructive`. When the prod database refuses the schema
|
|
56
|
+
* because applying it deletes data, the ops are shown and the call goes again with it — at once
|
|
57
|
+
* under `--allow-destructive`, after a y/N in a terminal, never without one (the error names the
|
|
58
|
+
* flag). A schema that cannot migrate at all is a plain error. null = the developer said no.
|
|
59
|
+
*/
|
|
60
|
+
export const withSchemaConfirm = async <T>(
|
|
61
|
+
call: (allowDestructive: boolean) => Promise<T>,
|
|
62
|
+
opts: { allowDestructive: boolean, retry: string },
|
|
63
|
+
): Promise<T | null> => {
|
|
64
|
+
try {
|
|
65
|
+
return await call(false)
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (!(e instanceof SchemaChangeError)) throw e
|
|
68
|
+
if (e.incompatible !== null) {
|
|
69
|
+
throw new CliError(`The production database cannot migrate to this schema in place${e.incompatible ? `: ${e.incompatible}` : ""}. Nothing changed.`, 409)
|
|
70
|
+
}
|
|
71
|
+
warn("The schema change deletes data in the production database:")
|
|
72
|
+
for (const op of e.destructive) log(c.red(` ${op}`))
|
|
73
|
+
if (!opts.allowDestructive) {
|
|
74
|
+
if (!process.stdin.isTTY) throw new CliError(`Nothing changed. Run \`${opts.retry} --allow-destructive\` to apply it.`, 409)
|
|
75
|
+
if (!(await confirm("Delete that data and go on?", false))) { info(c.dim("Cancelled — nothing changed.")); return null }
|
|
76
|
+
}
|
|
77
|
+
return await call(true)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const allowDestructiveFlag = { "allow-destructive": bool("apply a schema change that deletes production data without asking") }
|
|
82
|
+
|
|
83
|
+
const use = defineCommand({
|
|
84
|
+
name: "use",
|
|
85
|
+
usage: "<version>",
|
|
86
|
+
summary: "Make a release current again — a rollback, no compile",
|
|
87
|
+
description: "The release's bundle becomes what the project's address, the viewers and the store run, and its server is deployed to production again (the database migrates to that release's schema: a change that deletes data is shown and asked about, like a publish).",
|
|
88
|
+
flags: allowDestructiveFlag,
|
|
89
|
+
examples: ["lecodes releases use 1.2.3"],
|
|
90
|
+
run: async ({ args, flags }) => {
|
|
91
|
+
const version = args[0]?.replace(/^v/, "")
|
|
92
|
+
if (!version) throw new UsageError("Which release? `lecodes releases` lists them.")
|
|
93
|
+
const { apiUrl, token, manifest } = linkedProject()
|
|
94
|
+
const releases = await listReleases(apiUrl, token, manifest.uuid)
|
|
95
|
+
const target = releases.find(r => r.version === version)
|
|
96
|
+
if (!target) throw new CliError(`${manifest.name} has no release ${version} — \`lecodes releases\` lists them.`)
|
|
97
|
+
const previous = releases.find(r => r.current)
|
|
98
|
+
|
|
99
|
+
const res = await withSchemaConfirm(
|
|
100
|
+
allowDestructive => useRelease(apiUrl, token, manifest.uuid, version, { allowDestructive }),
|
|
101
|
+
{ allowDestructive: flags["allow-destructive"], retry: `lecodes releases use ${version}` },
|
|
102
|
+
)
|
|
103
|
+
if (!res) return
|
|
104
|
+
const back = previous !== undefined && compareVersions(res.version, previous.version) < 0
|
|
105
|
+
reportRelease({ ...target, ...res }, qrUrl(apiUrl, manifest.uuid), back ? "Rolled back to" : "Published")
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `releases upload <app.js>`: a bundle compiled here becomes the live release, no build on the
|
|
111
|
+
* platform — the way out when the build service's SDK makes bundles an app still in the stores
|
|
112
|
+
* cannot run (its SDK line is older). Compile with the CLI of that SDK and `--remote-assets`, so
|
|
113
|
+
* the bundle names the project's uploaded resources the way the platform's own builds do (the
|
|
114
|
+
* release pins them); `<app.js>.map` beside it goes up with it. Local changes are pushed first as
|
|
115
|
+
* the release's checkpoint, as `publish` does. The platform reads the bundle's `// sdk:` into the
|
|
116
|
+
* release — `lecodes releases` shows it — and refuses a project with server code (its server is
|
|
117
|
+
* built with the client).
|
|
118
|
+
*/
|
|
119
|
+
const upload = defineCommand({
|
|
120
|
+
name: "upload",
|
|
121
|
+
usage: "<app.js>",
|
|
122
|
+
summary: "Make a bundle compiled here the current release — no build on the platform",
|
|
123
|
+
description: "For a bundle the platform's build service cannot make any more: compiled with an older SDK's CLI (`compile --remote-assets`, so it names the project's uploaded resources), it becomes the current release with its own `// sdk:` — what the viewers of that SDK line run. Local changes are pushed first as the release's checkpoint. A project with server code cannot take one (its server is built with the client).",
|
|
124
|
+
flags: { message: str("checkpoint message when there are local changes (default \"Release\")", { alias: "m", value: "<text>" }) },
|
|
125
|
+
examples: ["lecodes releases upload app.js", "lecodes releases upload build/app.js -m \"Kungur for the 2.1 app\""],
|
|
126
|
+
run: async ({ args, flags }) => {
|
|
127
|
+
const file = args[0]
|
|
128
|
+
if (!file) throw new UsageError("Which bundle? `lecodes releases upload <app.js>`.")
|
|
129
|
+
const path = resolve(file)
|
|
130
|
+
if (!existsSync(path)) throw new CliError(`No such file: ${file}`)
|
|
131
|
+
const code = readFileSync(path, "utf8")
|
|
132
|
+
const sdk = /^\/\/ sdk: (\S+)/m.exec(code)?.[1]
|
|
133
|
+
if (!sdk) throw new CliError(`${file} is not a LeCodes bundle: no \`// sdk:\` header line. \`lecodes compile --remote-assets -o app.js\` writes one.`)
|
|
134
|
+
const map = existsSync(`${path}.map`) ? readFileSync(`${path}.map`, "utf8") : null
|
|
135
|
+
|
|
136
|
+
const { root, manifest, apiUrl, token } = linkedProject()
|
|
137
|
+
if (hasChanges(diffLocal(manifest, scanProject(root, manifest)))) await runPush({ message: flags.message || "Release" })
|
|
138
|
+
const release = await uploadReleaseBundle(apiUrl, token, manifest.uuid, { code, map })
|
|
139
|
+
reportRelease(release, qrUrl(apiUrl, manifest.uuid), "Uploaded")
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
export default defineCommand({
|
|
144
|
+
name: "releases",
|
|
145
|
+
summary: "List the project's releases (● = the current one); `releases use <version>` rolls back, `releases upload <app.js>` releases a bundle compiled here",
|
|
146
|
+
description: "Every `lecodes publish` (or the editor's Publish button) makes a release: an immutable build with its own version. The current one is what https://<handle>.le.codes, the viewers and the store run.",
|
|
147
|
+
commands: [use, upload],
|
|
148
|
+
examples: ["lecodes releases", "lecodes releases use 1.2.3", "lecodes releases upload app.js"],
|
|
149
|
+
run: async () => {
|
|
150
|
+
const { apiUrl, token, manifest } = linkedProject()
|
|
151
|
+
const releases = await listReleases(apiUrl, token, manifest.uuid)
|
|
152
|
+
if (releases.length === 0) {
|
|
153
|
+
info(`${manifest.name} has no releases yet — \`lecodes publish\` makes the first.`)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
table([
|
|
157
|
+
["", c.dim("VERSION"), c.dim("DATE"), c.dim("SIZE"), c.dim("SDK")],
|
|
158
|
+
...releases.map(r => [
|
|
159
|
+
r.current ? c.green("●") : "",
|
|
160
|
+
r.current ? c.bold(r.version) : r.version,
|
|
161
|
+
relativeTime(r.createdAt),
|
|
162
|
+
formatSize(r.size),
|
|
163
|
+
r.sdk,
|
|
164
|
+
]),
|
|
165
|
+
])
|
|
166
|
+
if (!releases.some(r => r.current)) info(c.dim(" Unpublished: no release is current."))
|
|
167
|
+
},
|
|
168
|
+
})
|
|
@@ -41,7 +41,7 @@ export type DesktopPlanStep = {
|
|
|
41
41
|
paintDump?: string
|
|
42
42
|
/** A `dump` output step: the file the host wrote. */
|
|
43
43
|
dump?: string
|
|
44
|
-
/** Expect keys the native host can't evaluate (`audio`: the headless
|
|
44
|
+
/** Expect keys the native host can't evaluate (`audio`, `toast`: the headless renderer's records) — reported as skipped. */
|
|
45
45
|
skippedExpect: string[]
|
|
46
46
|
/** Outputs the native host can't produce (`json`: the headless renderer's semantic shape — use `dump`). */
|
|
47
47
|
skippedOutputs: string[]
|
|
@@ -58,7 +58,7 @@ export type DesktopPlan = {
|
|
|
58
58
|
|
|
59
59
|
const ACTIONS = ["tap", "type", "fill", "scroll", "wait", "frames", "drag", "hold", "hover", "key", "keyDown", "keyUp", "look", "mouseTo", "wheel", "stick", "gamepad", "camera", "view"] as const
|
|
60
60
|
const SEMANTIC_EXPECT = ["text", "notText", "node", "notNode", "screen", "value"]
|
|
61
|
-
const UNSUPPORTED_EXPECT = ["audio"]
|
|
61
|
+
const UNSUPPORTED_EXPECT = ["audio", "toast"]
|
|
62
62
|
const VIEW_NAMES = ["iso", "top", "front", "side", "back"]
|
|
63
63
|
const isVec3 = (v: unknown): v is [number, number, number] => Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === "number")
|
|
64
64
|
|
|
@@ -362,7 +362,7 @@ export const evaluateDesktopRun = (plan: DesktopPlan, output: string, readFile:
|
|
|
362
362
|
let ok = true
|
|
363
363
|
for (const st of plan.steps) {
|
|
364
364
|
const skipped = [
|
|
365
|
-
...st.skippedExpect.map((k) => `expect.${k} (the headless
|
|
365
|
+
...st.skippedExpect.map((k) => `expect.${k} (the headless renderer's record — not on a native host)`),
|
|
366
366
|
...st.skippedOutputs.map((k) => `${k} output (the headless renderer's shape — use "dump" on a native host)`),
|
|
367
367
|
]
|
|
368
368
|
let error: string | undefined
|
package/src/platform/api.ts
CHANGED
|
@@ -37,12 +37,27 @@ export type SyncResponse = {
|
|
|
37
37
|
headId: number
|
|
38
38
|
seq?: number
|
|
39
39
|
nodes: { id: number, path: string, type: string, versionId: number | null }[]
|
|
40
|
-
/** Set when the push requested `publish
|
|
40
|
+
/** Set when the push requested `publish`: whether the release went out. */
|
|
41
41
|
published?: boolean
|
|
42
|
-
/**
|
|
42
|
+
/** The publish found the head to be the live release already and made none (as in `release`). */
|
|
43
|
+
unchanged?: true
|
|
44
|
+
/** The release, when `published`. */
|
|
45
|
+
release?: PublishResult
|
|
46
|
+
/** A human-readable reason when `publish` was requested but the release failed (push still landed). */
|
|
43
47
|
publishError?: string
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
/** A release that went live: `url` = the project's origin (`https://<handle>.le.codes/`).
|
|
51
|
+
* `unchanged`: the head was the live release already — no release was made, these are its values. */
|
|
52
|
+
export type PublishResult = { unchanged?: true, version: string, handle: string, url: string, size: number, sdk: string }
|
|
53
|
+
|
|
54
|
+
/** One release of `GET /projects/:uuid/publish` (newest first). */
|
|
55
|
+
export type Release = { version: string, sdk: string, createdAt: string, size: number, hasServer: boolean, current: boolean }
|
|
56
|
+
|
|
57
|
+
/** `GET /projects/:uuid/publish`: the project's publishing state. (`/projects/:uuid/releases` is the
|
|
58
|
+
* LIBRARY releases route, not these.) */
|
|
59
|
+
export type PublishState = { handle: string | null, url: string | null, webEnabled: boolean, live: unknown, releases: Release[] }
|
|
60
|
+
|
|
46
61
|
const errorMessage = (body: unknown, status: number): string => {
|
|
47
62
|
if (typeof body === "string" && body) return body
|
|
48
63
|
if (body && typeof body === "object") {
|
|
@@ -62,6 +77,16 @@ const errorMessage = (body: unknown, status: number): string => {
|
|
|
62
77
|
|
|
63
78
|
type RequestOptions = { method?: string, body?: unknown, token?: string }
|
|
64
79
|
|
|
80
|
+
/** An HTTP failure: the CliError (message + status) plus the parsed body, for a caller that reads
|
|
81
|
+
* more of the answer than its sentence (the 409 of a schema change). */
|
|
82
|
+
export class ApiError extends CliError {
|
|
83
|
+
readonly body: unknown
|
|
84
|
+
constructor(message: string, status: number, body: unknown) {
|
|
85
|
+
super(message, status)
|
|
86
|
+
this.body = body
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
65
90
|
export const apiRequest = async <T = unknown>(apiUrl: string, path: string, opts: RequestOptions = {}): Promise<T> => {
|
|
66
91
|
const headers: Record<string, string> = {}
|
|
67
92
|
const isForm = opts.body instanceof FormData
|
|
@@ -84,7 +109,7 @@ export const apiRequest = async <T = unknown>(apiUrl: string, path: string, opts
|
|
|
84
109
|
: contentType.startsWith("text/") ? await resp.text()
|
|
85
110
|
: null
|
|
86
111
|
|
|
87
|
-
if (resp.status >= 400) throw new
|
|
112
|
+
if (resp.status >= 400) throw new ApiError(errorMessage(parsed, resp.status), resp.status, parsed)
|
|
88
113
|
return parsed as T
|
|
89
114
|
}
|
|
90
115
|
|
|
@@ -117,13 +142,94 @@ export const uploadBlob = (apiUrl: string, token: string, uuid: string, name: st
|
|
|
117
142
|
return apiRequest<{ id: number, src: string, size: number }>(apiUrl, `/projects/${uuid}/blobs`, { token, body: form })
|
|
118
143
|
}
|
|
119
144
|
|
|
120
|
-
|
|
145
|
+
/** `publish` releases the project after the push; `force` beside it makes a release even when the
|
|
146
|
+
* head is the live one already. */
|
|
147
|
+
export const sync = (apiUrl: string, token: string, uuid: string, payload: { message: string, files: SyncFileInput[], folders: SyncFolderInput[], publish?: boolean, force?: boolean }) =>
|
|
121
148
|
apiRequest<SyncResponse>(apiUrl, `/projects/${uuid}/sync`, { token, body: payload })
|
|
122
149
|
|
|
123
|
-
|
|
124
|
-
*
|
|
125
|
-
|
|
126
|
-
|
|
150
|
+
/* ---- Releases (docs/plans/publishing-plan.md) ---------------------------------------------
|
|
151
|
+
* A publish compiles the head (checkpointed by the platform when dirty), deploys its server to the
|
|
152
|
+
* prod runner and makes the release current; `use` makes an older (or newer) release current again
|
|
153
|
+
* without a compile. Both can be refused over the prod database's schema — the editor's 409. */
|
|
154
|
+
|
|
155
|
+
/** The prod database refused the release's schema. `destructive` = the ops that delete data (the
|
|
156
|
+
* call goes through again with `allowDestructive`); `incompatible` = marcidb's reason the schema
|
|
157
|
+
* cannot migrate in place at all (no flag takes it; null when it can). */
|
|
158
|
+
export class SchemaChangeError extends CliError {
|
|
159
|
+
readonly destructive: string[]
|
|
160
|
+
readonly incompatible: string | null
|
|
161
|
+
constructor(message: string, destructive: string[], incompatible: string | null) {
|
|
162
|
+
super(message, 409)
|
|
163
|
+
this.destructive = destructive
|
|
164
|
+
this.incompatible = incompatible
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A plan op as one line: the platform relays the runner's `{ op, entity, field?, text }` objects. */
|
|
169
|
+
const opText = (op: unknown): string => {
|
|
170
|
+
if (typeof op === "string") return op
|
|
171
|
+
if (op && typeof op === "object" && typeof (op as { text?: unknown }).text === "string") return (op as { text: string }).text
|
|
172
|
+
return JSON.stringify(op)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The 409 `{ error, destructive?, incompatible? }` as a SchemaChangeError; anything else unchanged
|
|
176
|
+
* (a 409 "a publish is already under way" is a plain refusal). */
|
|
177
|
+
export const schemaChangeOf = (e: unknown): unknown => {
|
|
178
|
+
if (!(e instanceof ApiError) || e.status !== 409 || !e.body || typeof e.body !== "object") return e
|
|
179
|
+
const body = e.body as { error?: unknown, destructive?: unknown, incompatible?: unknown }
|
|
180
|
+
const destructive = Array.isArray(body.destructive) ? body.destructive.map(opText) : null
|
|
181
|
+
const incompatible = typeof body.incompatible === "string" ? body.incompatible : body.incompatible === true ? "" : null
|
|
182
|
+
if (!destructive && incompatible === null) return e
|
|
183
|
+
return new SchemaChangeError(typeof body.error === "string" ? body.error : "The schema change needs a confirmation", destructive ?? [], incompatible)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A release call's failure in the CLI's words: the schema 409 typed, a build error (400, publish
|
|
187
|
+
* only) said as one, a service of ours being down (502 / 503) said as ours, not the project's. */
|
|
188
|
+
const releaseFailure = (e: unknown, opts: { build: boolean }): unknown => {
|
|
189
|
+
const schema = schemaChangeOf(e)
|
|
190
|
+
if (schema !== e || !(e instanceof ApiError)) return schema
|
|
191
|
+
if (opts.build && e.status === 400) return new CliError(`Build failed:
|
|
192
|
+
${e.message}`, 400)
|
|
193
|
+
if (e.status === 502 || e.status === 503) {
|
|
194
|
+
// An nginx error page is HTML — its text says nothing the sentence doesn't.
|
|
195
|
+
const detail = e.message.trimStart().startsWith("<") ? "" : `: ${e.message.trim()}`
|
|
196
|
+
const service = opts.build ? "The platform's build service is unavailable" : "The platform could not reach the app backend"
|
|
197
|
+
return new CliError(`${service} (${e.status})${detail}. Nothing changed — try again in a minute.`, e.status)
|
|
198
|
+
}
|
|
199
|
+
return e
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Publish the project's head (= the editor's Publish button). `unchanged` when it is the live
|
|
203
|
+
* release already — `force` makes a release all the same. */
|
|
204
|
+
export const publishProject = async (apiUrl: string, token: string, uuid: string, opts: { allowDestructive?: boolean, force?: boolean } = {}): Promise<PublishResult> => {
|
|
205
|
+
try {
|
|
206
|
+
const body = { ...(opts.allowDestructive ? { allowDestructive: true } : {}), ...(opts.force ? { force: true } : {}) }
|
|
207
|
+
return await apiRequest<PublishResult>(apiUrl, `/projects/${uuid}/publish`, { token, body })
|
|
208
|
+
} catch (e) { throw releaseFailure(e, { build: true }) }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The project's releases, newest first; `current` marks the live one. */
|
|
212
|
+
export const listReleases = async (apiUrl: string, token: string, uuid: string): Promise<Release[]> =>
|
|
213
|
+
(await apiRequest<PublishState>(apiUrl, `/projects/${uuid}/publish`, { token })).releases
|
|
214
|
+
|
|
215
|
+
/** Make an existing release current (a rollback, or forward again) — no compile. */
|
|
216
|
+
export const useRelease = async (apiUrl: string, token: string, uuid: string, version: string, opts: { allowDestructive?: boolean } = {}): Promise<{ version: string, handle: string, url: string }> => {
|
|
217
|
+
try {
|
|
218
|
+
return await apiRequest<{ version: string, handle: string, url: string }>(
|
|
219
|
+
apiUrl, `/projects/${uuid}/releases/${encodeURIComponent(version)}/use`, { token, body: opts.allowDestructive ? { allowDestructive: true } : {} })
|
|
220
|
+
} catch (e) { throw releaseFailure(e, { build: false }) }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A bundle compiled here as the live release — of the SDK its header names, no build on the
|
|
224
|
+
* platform (`lecodes releases upload`). A refusal (no `// sdk:`, a project with server code) is a 400 as text. */
|
|
225
|
+
export const uploadReleaseBundle = async (apiUrl: string, token: string, uuid: string, bundle: { code: string, map: string | null }): Promise<PublishResult> => {
|
|
226
|
+
const form = new FormData()
|
|
227
|
+
form.append("app", new Blob([ bundle.code ], { type: "text/javascript" }), "app.js")
|
|
228
|
+
if (bundle.map !== null) form.append("map", new Blob([ bundle.map ], { type: "application/json" }), "app.js.map")
|
|
229
|
+
try {
|
|
230
|
+
return await apiRequest<PublishResult>(apiUrl, `/projects/${uuid}/releases/upload`, { token, body: form })
|
|
231
|
+
} catch (e) { throw releaseFailure(e, { build: false }) }
|
|
232
|
+
}
|
|
127
233
|
|
|
128
234
|
export type DesignShare = {
|
|
129
235
|
token: string | null
|
package/src/plugin/generate.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { emitSwift } from "./emitSwift"
|
|
|
17
17
|
import { emitWeb } from "./emitWeb"
|
|
18
18
|
import { lower } from "./lower"
|
|
19
19
|
import { MANIFEST_FILE, ManifestError, readManifestIfAny, type PluginManifest } from "./manifest"
|
|
20
|
-
import { missingDeclarations, usesPermissions, type PermissionKind } from "./permissions"
|
|
20
|
+
import { missingDeclarations, usesFeatures, usesPermissions, type PermissionKind } from "./permissions"
|
|
21
21
|
import { DEFAULT_SDK_PATH, PACKAGE_FILE, PackageError, emitPackageSwift } from "./emitPackage"
|
|
22
22
|
|
|
23
23
|
// A path as the report shows it: relative to `from`, with `/` on every platform (a report is
|
|
@@ -89,21 +89,24 @@ export const generatePlugin = (ts: typeof tsModule, plugin: PluginDir): { contra
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/** The declarations the contract's `@permission` kinds need, held against the plugin's manifests:
|
|
92
|
-
* the usage strings of ios.infoPlist (a missing one crashes the app at the prompt)
|
|
92
|
+
* the usage strings of ios.infoPlist (a missing one crashes the app at the prompt), the
|
|
93
93
|
* `<uses-permission>` elements of android/src/main/AndroidManifest.xml (a missing one is a
|
|
94
|
-
* silent refusal)
|
|
95
|
-
*
|
|
94
|
+
* silent refusal) and the `<uses-feature android:required="false">` of each feature those
|
|
95
|
+
* permissions imply (a missing one makes Play require the hardware). Checked where the plugin has
|
|
96
|
+
* the half; a plugin without a manifest (a project's own, the shell declares for it) is not held. */
|
|
96
97
|
const checkPermissions = (plugin: PluginDir, contract: Contract, manifest: PluginManifest | null): void => {
|
|
97
98
|
if (!manifest) return
|
|
98
99
|
const kinds: PermissionKind[] = []
|
|
99
100
|
for (const ch of contract.channels) for (const m of ch.methods) for (const k of m.permissions) if (!kinds.includes(k)) kinds.push(k)
|
|
100
101
|
if (kinds.length === 0) return
|
|
101
102
|
const androidManifest = join(plugin.dir, "android", "src", "main", "AndroidManifest.xml")
|
|
102
|
-
const
|
|
103
|
-
const missing = missingDeclarations(kinds, manifest.ios ? manifest.ios.infoPlist ?? {} : null,
|
|
103
|
+
const androidXml = manifest.android ? (existsSync(androidManifest) ? readFileSync(androidManifest, "utf8") : "") : null
|
|
104
|
+
const missing = missingDeclarations(kinds, manifest.ios ? manifest.ios.infoPlist ?? {} : null,
|
|
105
|
+
androidXml === null ? null : usesPermissions(androidXml), androidXml === null ? null : usesFeatures(androidXml))
|
|
104
106
|
const problems: string[] = []
|
|
105
107
|
if (missing.infoPlist.length) problems.push(`ios.infoPlist: the contract's @permission needs ${missing.infoPlist.join(", ")} — the usage string the dialog shows`)
|
|
106
108
|
if (missing.android.length) problems.push(`android/src/main/AndroidManifest.xml: the contract's @permission needs <uses-permission android:name="…"/> for ${missing.android.join(", ")}`)
|
|
109
|
+
if (missing.androidFeatures.length) problems.push(`android/src/main/AndroidManifest.xml: the contract's @permission implies <uses-feature android:name="…" android:required="false"/> for ${missing.androidFeatures.join(", ")} — without it Play requires the hardware`)
|
|
107
110
|
if (problems.length) throw new ManifestError(join(plugin.dir, MANIFEST_FILE), problems)
|
|
108
111
|
}
|
|
109
112
|
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// asks nothing (the browser's API prompts itself). A kind's needs on each platform are known here,
|
|
5
5
|
// so `lecodes plugin gen` can hold the plugin's manifests to them: the Info.plist usage strings the
|
|
6
6
|
// manifest declares (ios.infoPlist) and the `<uses-permission>` elements of the Android manifest.
|
|
7
|
+
// Android implies a REQUIRED hardware feature from some of those permissions (RECORD_AUDIO → the
|
|
8
|
+
// microphone), so the Android manifest also declares each one `<uses-feature android:required="false">`
|
|
9
|
+
// — else Play requires the hardware of every device the app installs on.
|
|
7
10
|
//
|
|
8
11
|
// The kinds are the SDK's (`permissions.query / request` in lecodes-sdk: sdk/src/runtime/permissions.ts).
|
|
9
12
|
|
|
@@ -13,32 +16,57 @@ export type PermissionKind = (typeof PERMISSION_KINDS)[number]
|
|
|
13
16
|
export const isPermissionKind = (s: string): s is PermissionKind => (PERMISSION_KINDS as readonly string[]).includes(s)
|
|
14
17
|
|
|
15
18
|
/** What a kind needs declared: Info.plist keys (every one), Android permissions (every group has
|
|
16
|
-
* one of its names in the manifest — location takes FINE or COARSE)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
* one of its names in the manifest — location takes FINE or COARSE), and the Android features those
|
|
20
|
+
* permissions imply (every one declared `android:required="false"`). */
|
|
21
|
+
export const PERMISSION_NEEDS: Record<PermissionKind, { infoPlist: string[], android: string[][], androidFeatures: string[] }> = {
|
|
22
|
+
camera: { infoPlist: ["NSCameraUsageDescription"], android: [["android.permission.CAMERA"]], androidFeatures: ["android.hardware.camera"] },
|
|
23
|
+
microphone: {
|
|
24
|
+
infoPlist: ["NSMicrophoneUsageDescription"],
|
|
25
|
+
android: [["android.permission.RECORD_AUDIO"]],
|
|
26
|
+
androidFeatures: ["android.hardware.microphone"],
|
|
27
|
+
},
|
|
20
28
|
location: {
|
|
21
29
|
infoPlist: ["NSLocationWhenInUseUsageDescription"],
|
|
22
30
|
android: [["android.permission.ACCESS_FINE_LOCATION", "android.permission.ACCESS_COARSE_LOCATION"]],
|
|
31
|
+
androidFeatures: ["android.hardware.location"],
|
|
23
32
|
},
|
|
24
33
|
// Speech recognition: its own purpose string on iOS, the microphone's permission on Android. A
|
|
25
34
|
// method that recognizes live speech tags `microphone` beside it (the audio is the microphone's).
|
|
26
|
-
speech: {
|
|
35
|
+
speech: {
|
|
36
|
+
infoPlist: ["NSSpeechRecognitionUsageDescription"],
|
|
37
|
+
android: [["android.permission.RECORD_AUDIO"]],
|
|
38
|
+
androidFeatures: ["android.hardware.microphone"],
|
|
39
|
+
},
|
|
27
40
|
}
|
|
28
41
|
|
|
29
42
|
/** The `android:name`s of the `<uses-permission>` elements of an AndroidManifest.xml. */
|
|
30
43
|
export const usesPermissions = (xml: string): string[] =>
|
|
31
44
|
[...xml.matchAll(/<uses-permission(?:-sdk-23)?\b[^>]*\bandroid:name\s*=\s*"([^"]+)"/g)].map(m => m[1]!)
|
|
32
45
|
|
|
46
|
+
/** The `<uses-feature>` elements of an AndroidManifest.xml that name a feature (a `glEsVersion` one
|
|
47
|
+
* names none), in any attribute order. `android:required` absent = true, Android's default. */
|
|
48
|
+
export const usesFeatures = (xml: string): { name: string, required: boolean }[] => {
|
|
49
|
+
const out: { name: string, required: boolean }[] = []
|
|
50
|
+
for (const m of xml.replace(/<!--[\s\S]*?-->/g, "").matchAll(/<uses-feature\b[^>]*>/g)) {
|
|
51
|
+
const attr = (re: RegExp): string | undefined => re.exec(m[0])?.[2]
|
|
52
|
+
const name = attr(/\bandroid:name\s*=\s*(["'])(.*?)\1/)
|
|
53
|
+
if (name) out.push({ name, required: attr(/\bandroid:required\s*=\s*(["'])(.*?)\1/)?.trim() !== "false" })
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
33
58
|
/** The declarations the kinds need and the manifests lack: `infoPlist` keys missing from the iOS
|
|
34
|
-
* manifest's ios.infoPlist (null = no iOS half)
|
|
35
|
-
* Android manifest
|
|
59
|
+
* manifest's ios.infoPlist (null = no iOS half); `android` permission groups with no name in the
|
|
60
|
+
* Android manifest, and `androidFeatures` the permissions imply that it does not declare
|
|
61
|
+
* `android:required="false"` — absent, or declared required: a plugin never requires hardware, the
|
|
62
|
+
* app may (both null = no Android half). Each entry names the kind that needs it. */
|
|
36
63
|
export const missingDeclarations = (
|
|
37
64
|
kinds: PermissionKind[],
|
|
38
65
|
infoPlist: Record<string, string> | null,
|
|
39
66
|
androidNames: string[] | null,
|
|
40
|
-
|
|
41
|
-
|
|
67
|
+
androidFeatures: { name: string, required: boolean }[] | null,
|
|
68
|
+
): { infoPlist: string[], android: string[], androidFeatures: string[] } => {
|
|
69
|
+
const out = { infoPlist: [] as string[], android: [] as string[], androidFeatures: [] as string[] }
|
|
42
70
|
for (const kind of kinds) {
|
|
43
71
|
const needs = PERMISSION_NEEDS[kind]
|
|
44
72
|
if (infoPlist) for (const key of needs.infoPlist) {
|
|
@@ -47,6 +75,10 @@ export const missingDeclarations = (
|
|
|
47
75
|
if (androidNames) for (const group of needs.android) {
|
|
48
76
|
if (!group.some(name => androidNames.includes(name))) out.android.push(`${group.join(" or ")} (${kind})`)
|
|
49
77
|
}
|
|
78
|
+
if (androidFeatures) for (const feature of needs.androidFeatures) {
|
|
79
|
+
const declared = androidFeatures.filter(f => f.name === feature)
|
|
80
|
+
if (declared.length === 0 || declared.some(f => f.required)) out.androidFeatures.push(`${feature} (${kind})`)
|
|
81
|
+
}
|
|
50
82
|
}
|
|
51
|
-
return { infoPlist: [...new Set(out.infoPlist)], android: [...new Set(out.android)] }
|
|
83
|
+
return { infoPlist: [...new Set(out.infoPlist)], android: [...new Set(out.android)], androidFeatures: [...new Set(out.androidFeatures)] }
|
|
52
84
|
}
|