lecodes-cli 2.2.11 → 2.2.14

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.
@@ -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
+ })
@@ -4,11 +4,12 @@ import { tmpdir } from "node:os"
4
4
  import { join } from "node:path"
5
5
  import { format } from "node:util"
6
6
  import {
7
- createAuthHost, createFilesHost, createInvoker, createSubscribeGuard, describeServer, loadServerModules, runSeed,
8
- setChannelPublisher, setDbTransport, type AuthHost, type Heic, type LoadedServer, type Sharp,
7
+ createAuthHost, createFilesHost, createInvoker, createPushHost, createSubscribeGuard, describeServer, loadServerModules, runSeed,
8
+ setChannelPublisher, setDbTransport, type AuthHost, type Heic, type LoadedServer, type PushRelay, type Sharp,
9
9
  } from "lecodes-sdk/server"
10
10
  import { loadPeer } from "../hosts/peerInstall"
11
- import type { FromWorker, LogLevel, ToWorker } from "./localBackend"
11
+ import { ApiError, sendPush } from "../platform/api"
12
+ import type { FromWorker, LogLevel, PushSender, ToWorker } from "./localBackend"
12
13
 
13
14
  /*
14
15
  * The process a project's server bundle runs in under `lecodes dev` — the local twin of the runner's
@@ -96,6 +97,21 @@ const openDb = async (dir: string, filesDir: string, schema: string): Promise<st
96
97
  }
97
98
  }
98
99
 
100
+ /** Push notifications go out through the platform's send route on the developer's own login, as `lecodes pn send`
101
+ * does — a project push key is never needed here. The platform's 404 for an address it knows no more
102
+ * (`unknown_addresses`) is answered as `unknown`, for the host to drop and send the rest again. */
103
+ const pushRelay = (sender: PushSender): PushRelay => async (to, message) => {
104
+ try {
105
+ return await sendPush(sender.apiUrl, sender.token, sender.uuid, { to, ...message })
106
+ } catch (e) {
107
+ const body = e instanceof ApiError ? e.body as { code?: string, addresses?: unknown } | null : null
108
+ if (body?.code === "unknown_addresses" && Array.isArray(body.addresses)) {
109
+ return { sent: 0, failed: [], unknown: body.addresses.filter((a): a is string => typeof a === "string") }
110
+ }
111
+ throw new Error(`push: the platform refused the send: ${(e as Error).message}`)
112
+ }
113
+ }
114
+
99
115
  const load = async (m: Extract<ToWorker, { t: "load" }>) => {
100
116
  const noDb = () => { throw new Error("db: the database is not open yet — query it inside an endpoint, not while the module loads") }
101
117
  setDbTransport({ exec: (op) => (db ?? noDb()).exec(op), batch: (ops) => (db ?? noDb()).batch(ops) })
@@ -116,6 +132,13 @@ const load = async (m: Extract<ToWorker, { t: "load" }>) => {
116
132
  log: (level, text) => post({ t: "log", level, text }),
117
133
  })
118
134
  authHost.install()
135
+ // Push channels: a user's devices are the sessions of the db with sign-in; the notifications go out through the platform
136
+ // when this checkout is linked and the developer is logged in.
137
+ createPushHost({
138
+ db: server.dbs.find(d => d.$auth) ?? null,
139
+ relay: m.push ? pushRelay(m.push) : null,
140
+ log: (level, text) => post({ t: "log", level, text }),
141
+ }).install()
119
142
  const auth = authHost.enabled ? authHost : null
120
143
  const onError = (id: string, e: unknown) => post({ t: "log", level: "error", text: `${id}: ${(e as Error)?.stack ?? e}` })
121
144
  // Stored files (`t.file()`): rows in the database, bytes in the folder beside it
@@ -25,7 +25,7 @@ export type LogLevel = "log" | "info" | "warn" | "error" | "debug"
25
25
  type Ctx = Omit<RequestContext, "id">
26
26
 
27
27
  export type ToWorker =
28
- | { t: "load", code: string, dbDir: string, filesDir: string, root: string }
28
+ | { t: "load", code: string, dbDir: string, filesDir: string, root: string, push: PushSender | null }
29
29
  | { t: "invoke", req: number, id: string, args: unknown[], ctx: Ctx }
30
30
  | { t: "subscribe", req: number, ch: string, args: unknown[], ctx: Ctx }
31
31
  | { t: "stop" }
@@ -37,8 +37,13 @@ export type FromWorker =
37
37
  | { t: "publish", ch: string, group: ChannelGroup | null, data: unknown }
38
38
  | { t: "log", level: LogLevel, text: string }
39
39
 
40
+ /** How `pushChannel.send` reaches the platform's sender from this machine: the linked project and the developer's
41
+ * own login (what `lecodes pn send` sends with). `null` = not linked or not logged in: sends answer 0, said once. */
42
+ export type PushSender = { apiUrl: string, token: string, uuid: string }
43
+
40
44
  export type LocalBackendOptions = {
41
45
  root: string
46
+ push?: PushSender | null
42
47
  /** The database's folder (default `.lecodes/db`). */
43
48
  dbDir?: string
44
49
  /** The stored files' folder (`t.file()`; default: beside the database — `.lecodes/files`). */
@@ -147,7 +152,7 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
147
152
  if (!closed) log(`[server:error] the server process exited (${status ?? "killed"}) — it starts again with the next call`)
148
153
  }
149
154
  })
150
- child.send({ t: "load", code: bundle, dbDir, filesDir, root: options.root } satisfies ToWorker)
155
+ child.send({ t: "load", code: bundle, dbDir, filesDir, root: options.root, push: options.push ?? null } satisfies ToWorker)
151
156
  })
152
157
 
153
158
  const stop = async (l: Live) => {
@@ -3,7 +3,8 @@ import { join, relative } from "node:path"
3
3
  import { createChannelHub, fileResponse, isMultipart, receiveUploads, UploadError, type Upload } from "lecodes-sdk/server"
4
4
  import { compileServer, hasServerFiles, type CompileEntry } from "../compile/projectCompile"
5
5
  import { loadPeer } from "../hosts/peerInstall"
6
- import { LECODES_DIR } from "../project/manifest"
6
+ import { LECODES_DIR, readManifest } from "../project/manifest"
7
+ import { session } from "../commands/shared"
7
8
  import { readAppConfigAt } from "../commands/app/shared"
8
9
  import { filesDirOf, startLocalBackend, type LocalBackend } from "./localBackend"
9
10
  import { serve, type Served, type Socket, type SocketHandlers } from "./serve"
@@ -73,7 +74,12 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
73
74
  : Promise.resolve({ ok: false as const, status: 404, message: "The project has no server files" }),
74
75
  })
75
76
  const sockets = new Set<Socket>()
76
- const start = () => startLocalBackend({ root, dbDir, log, onPublish: (ch, group, data) => hub.publish("", ch, group, data) })
77
+ // `pushChannel.send` reaches the platform's sender through the developer's own login when the checkout is linked —
78
+ // the way `lecodes pn send` sends; a project standing alone, or no login, sends nothing (said once in the console)
79
+ const push = (() => {
80
+ try { const { apiUrl, token } = session(); return { apiUrl, token, uuid: readManifest(root).uuid } } catch { return null }
81
+ })()
82
+ const start = () => startLocalBackend({ root, dbDir, log, push, onPublish: (ch, group, data) => hub.publish("", ch, group, data) })
77
83
 
78
84
  const envelope = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json", "cache-control": "no-store", ...CORS } })
79
85
 
@@ -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 recorder's) — reported as skipped. */
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 audio recorder's — not on a native host)`),
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
@@ -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` — whether the deploy (compile → live) succeeded. */
40
+ /** Set when the push requested `publish`: whether the release went out. */
41
41
  published?: boolean
42
- /** A human-readable reason when `publish` was requested but the deploy failed (push still landed). */
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 CliError(errorMessage(parsed, resp.status), resp.status)
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
- export const sync = (apiUrl: string, token: string, uuid: string, payload: { message: string, files: SyncFileInput[], folders: SyncFolderInput[], publish?: boolean }) =>
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
- /** Deploy the project's current server state (compile → live bundle). Used by `push --publish` when
124
- * there are no file changes to sync. */
125
- export const publishProject = (apiUrl: string, token: string, uuid: string) =>
126
- apiRequest<{ published: boolean, size: number }>(apiUrl, `/projects/${uuid}/publish`, { token, body: {} })
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
@@ -247,6 +353,8 @@ export const deleteDesignCommentMessage = (
247
353
  export type PushSendPayload = {
248
354
  to?: string[]
249
355
  users?: string[]
356
+ /** The server's push channel the notification is of (`<path>#<export>`): the app's side hears each channel's own. */
357
+ channel?: string
250
358
  title: string
251
359
  body?: string
252
360
  url?: string
@@ -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) and the
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). Checked where the plugin has the half; a plugin without a manifest (a project's
95
- * own, the shell declares for it) is not held. */
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 androidNames = manifest.android ? (existsSync(androidManifest) ? usesPermissions(readFileSync(androidManifest, "utf8")) : []) : null
103
- const missing = missingDeclarations(kinds, manifest.ios ? manifest.ios.infoPlist ?? {} : null, androidNames)
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
- export const PERMISSION_NEEDS: Record<PermissionKind, { infoPlist: string[], android: string[][] }> = {
18
- camera: { infoPlist: ["NSCameraUsageDescription"], android: [["android.permission.CAMERA"]] },
19
- microphone: { infoPlist: ["NSMicrophoneUsageDescription"], android: [["android.permission.RECORD_AUDIO"]] },
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: { infoPlist: ["NSSpeechRecognitionUsageDescription"], android: [["android.permission.RECORD_AUDIO"]] },
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), `android` permission groups with no name in the
35
- * Android manifest (null = no Android half). Each entry names the kind that needs it. */
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
- ): { infoPlist: string[], android: string[] } => {
41
- const out = { infoPlist: [] as string[], android: [] as string[] }
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
  }