lecodes-cli 0.20.1 → 1.0.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.
Files changed (107) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +5920 -5022
  3. package/package.json +9 -7
  4. package/runtime/materials/decal-relief.mat +12 -1
  5. package/runtime/materials/decal.mat +17 -3
  6. package/runtime/materials/lightmap-baked-lite.mat +6 -1
  7. package/runtime/materials/lightmap-baked.mat +6 -1
  8. package/runtime/materials/lightmap.mat +3 -0
  9. package/runtime/materials/particles-quad.mat +17 -9
  10. package/runtime/materials/particles.mat +17 -9
  11. package/runtime/scene-harness.json +1 -1
  12. package/runtime/sdk-types.json +1 -1
  13. package/runtime/web/assets/createViewerLite-DHqTFhWB.js +866 -0
  14. package/runtime/web/assets/{index-BTB5KV9x.js → index-Q06c6oHx.js} +1 -1
  15. package/runtime/web/embed.js +2 -2
  16. package/src/api.ts +1 -302
  17. package/src/cli/args.ts +37 -0
  18. package/src/cli/command.ts +113 -0
  19. package/src/cli/errors.ts +13 -0
  20. package/src/cli/help.ts +133 -0
  21. package/src/cli/index.ts +7 -0
  22. package/src/cli/output.ts +102 -0
  23. package/src/cli/run.ts +106 -0
  24. package/src/commands/{appAndroid.ts → app/android.ts} +451 -42
  25. package/src/commands/{appDesktop.ts → app/desktop.ts} +63 -32
  26. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +24 -18
  27. package/src/commands/{appIcon.ts → app/icon.ts} +2 -2
  28. package/src/commands/{app.ts → app/index.ts} +397 -289
  29. package/src/commands/{appShared.ts → app/shared.ts} +30 -39
  30. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +62 -28
  31. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  32. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +1 -1
  33. package/src/commands/assets.ts +23 -21
  34. package/src/commands/clone.ts +48 -51
  35. package/src/commands/compile.ts +62 -88
  36. package/src/commands/create.ts +46 -47
  37. package/src/commands/design/comments.ts +361 -0
  38. package/src/commands/design/context.ts +101 -0
  39. package/src/commands/design/index.ts +391 -0
  40. package/src/commands/design/snapshot.ts +134 -0
  41. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  42. package/src/commands/desktop.ts +90 -82
  43. package/src/commands/dev.ts +201 -176
  44. package/src/commands/diff.ts +39 -51
  45. package/src/commands/index.ts +60 -0
  46. package/src/commands/{init.ts → init/index.ts} +199 -191
  47. package/src/commands/install.ts +80 -67
  48. package/src/commands/lightmap.ts +203 -171
  49. package/src/commands/link.ts +87 -94
  50. package/src/commands/login.ts +25 -23
  51. package/src/commands/navmesh.ts +168 -138
  52. package/src/commands/pn.ts +201 -244
  53. package/src/commands/pull.ts +49 -56
  54. package/src/commands/push.ts +48 -54
  55. package/src/commands/render.ts +158 -99
  56. package/src/commands/scene.ts +58 -55
  57. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  58. package/src/commands/{shadersNew.ts → shaders/new.ts} +2 -2
  59. package/src/commands/shared.ts +79 -0
  60. package/src/commands/status.ts +22 -26
  61. package/src/commands/test.ts +398 -371
  62. package/src/commands/thumbs.ts +185 -175
  63. package/src/commands/update/index.ts +226 -0
  64. package/src/commands/{types.ts → update/types.ts} +31 -22
  65. package/src/compile/collect.ts +5 -5
  66. package/src/compile/collectLocal.ts +3 -3
  67. package/src/compile/designCompile.ts +3 -3
  68. package/src/compile/headlessBundle.ts +133 -129
  69. package/src/compile/projectCompile.ts +2 -2
  70. package/src/compile/sceneCompile.ts +10 -8
  71. package/src/compile/screenEntry.ts +123 -127
  72. package/src/compile/shaders.ts +3 -3
  73. package/src/dev/androidDev.ts +1 -1
  74. package/src/dev/devServer.ts +3 -3
  75. package/src/dev/webRunner.ts +1 -1
  76. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +1 -1
  77. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +6 -5
  78. package/src/{desktopScript.ts → hosts/desktopScript.ts} +1 -1
  79. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  80. package/src/{matcTool.ts → hosts/matcTool.ts} +1 -1
  81. package/src/{peerInstall.ts → hosts/peerInstall.ts} +2 -2
  82. package/src/{releases.ts → hosts/releases.ts} +1 -1
  83. package/src/index.ts +32 -480
  84. package/src/platform/api.ts +302 -0
  85. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  86. package/src/{config.ts → platform/config.ts} +1 -1
  87. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  88. package/src/{projectEnv.ts → project/env.ts} +33 -2
  89. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  90. package/src/{manifest.ts → project/manifest.ts} +1 -1
  91. package/src/{project.ts → project/materialize.ts} +2 -2
  92. package/src/project/paths.ts +20 -0
  93. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  94. package/src/{types.ts → project/types.ts} +0 -0
  95. package/runtime/web/assets/createViewerLite-C_bkKXLg.js +0 -852
  96. package/src/commands/design.ts +0 -846
  97. package/src/commands/update.ts +0 -211
  98. package/src/util.ts +0 -146
  99. /package/src/commands/{projectTemplates.ts → init/templates.ts} +0 -0
  100. /package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +0 -0
  101. /package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +0 -0
  102. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  103. /package/src/{lecodes-renderer.d.ts → declarations/lecodes-renderer.d.ts} +0 -0
  104. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  105. /package/src/{peers.ts → hosts/peers.ts} +0 -0
  106. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
  107. /package/src/{ignore.ts → project/ignore.ts} +0 -0
@@ -1,12 +1,10 @@
1
- import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"
2
- import { join } from "node:path"
1
+ import { upsertDotEnv } from "../project/env"
3
2
  import {
4
3
  createPushKey, forgetPushDevice, listPushDevices, listPushKeys, revokePushKey, sendPush,
5
4
  type PushDevice, type PushSendPayload,
6
- } from "../api"
7
- import { loadConfig, requireApiUrl, requireToken } from "../config"
8
- import { findProjectRoot, readManifest } from "../manifest"
9
- import { CliError, c, confirm, flagBool, flagStr, info, log, success, warn, type Args } from "../util"
5
+ } from "../platform/api"
6
+ import { CliError, UsageError, bool, c, confirm, defineCommand, info, log, num, relativeTime, str, success, table, warn } from "../cli"
7
+ import { linkedProject, type LinkedProject } from "./shared"
10
8
 
11
9
  /*
12
10
  * `lecodes pn` — push notifications for this project (docs/push-plan.md). Named `pn` because
@@ -26,100 +24,75 @@ import { CliError, c, confirm, flagBool, flagStr, info, log, success, warn, type
26
24
 
27
25
  const ENV_VAR = "LECODES_PUSH_KEY"
28
26
 
29
- type Context = { root: string, uuid: string, name: string, apiUrl: string, token: string }
30
-
31
- const context = (): Context => {
32
- const root = findProjectRoot(process.cwd())
33
- const manifest = readManifest(root)
34
- const config = loadConfig()
35
- return { root, uuid: manifest.uuid, name: manifest.name, apiUrl: requireApiUrl(config), token: requireToken(config) }
36
- }
37
-
38
- /** A compact "3h ago" — absolute timestamps make these tables unreadable at a glance. */
39
- const ago = (iso: string | null): string => {
40
- if (!iso) return c.dim("never")
41
- const ms = Date.now() - new Date(iso).getTime()
42
- if (!Number.isFinite(ms)) return c.dim("—")
43
- const mins = Math.round(ms / 60_000)
44
- if (mins < 1) return "just now"
45
- if (mins < 60) return `${mins}m ago`
46
- const hours = Math.round(mins / 60)
47
- if (hours < 24) return `${hours}h ago`
48
- return `${Math.round(hours / 24)}d ago`
49
- }
50
-
51
- // Column widths must count PRINTED characters: on a TTY the cells carry ANSI color codes, and
52
- // padEnd would count those too — every colored column drifting a few chars wide.
53
- const ANSI = /\x1b\[[0-9;]*m/g
54
- const printed = (s: string) => s.replace(ANSI, "").length
55
-
56
- /** Print rows as aligned columns (last column unpadded so colors don't drag trailing spaces). */
57
- const table = (rows: string[][]) => {
58
- const widths = rows[0].map((_, i) => Math.max(...rows.map(r => printed(r[i] ?? ""))))
59
- for (const row of rows) {
60
- const cells = row.map((cell, i) =>
61
- i === row.length - 1 ? cell : cell + " ".repeat(Math.max(0, widths[i] - printed(cell))))
62
- log(" " + cells.join(" "))
63
- }
64
- }
65
-
66
- /** Comma-separated list flag. parseArgs keeps only the LAST of a repeated `--user`, so repeats
27
+ /** Comma-separated list flag. The parser keeps only the LAST of a repeated `--user`, so repeats
67
28
  * would silently drop targets — commas are the form that can't lose one. */
68
- const listFlag = (args: Args, ...keys: string[]): string[] => {
69
- const raw = flagStr(args, ...keys)
70
- if (!raw) return []
71
- return [ ...new Set(raw.split(",").map(s => s.trim()).filter(Boolean)) ]
72
- }
29
+ const listOf = (raw: string | undefined): string[] =>
30
+ raw ? [ ...new Set(raw.split(",").map(s => s.trim()).filter(Boolean)) ] : []
73
31
 
74
- /* ---------------------------------------- send ---------------------------------------- */
75
-
76
- /** `lecodes pn send --title … [--user u42] [--to pa_…]` — a real send through the relay,
77
- * authenticated with your own token. Targets are the same ones a production server uses. */
78
- const pnSend = async (args: Args) => {
79
- const ctx = context()
32
+ const confirmFlag = { yes: bool("skip the confirmation", { alias: "y" }) }
80
33
 
81
- const title = flagStr(args, "title", "t") ?? args._[1]
82
- if (!title) throw new CliError('A title is required: lecodes pn send --title "Order shipped" --user u42')
34
+ /* ---------------------------------------- send ---------------------------------------- */
83
35
 
84
- const users = listFlag(args, "user", "users", "u")
85
- const to = listFlag(args, "to", "address", "addresses")
86
- if (users.length === 0 && to.length === 0) {
87
- throw new CliError("Nothing to send to — pass --user <id> (from Push.register({ user })) or --to <pa_…>. `lecodes pn devices` lists both.")
88
- }
36
+ /** A real send through the relay, authenticated with your own token. Targets are the same ones a
37
+ * production server uses. */
38
+ const send = defineCommand({
39
+ name: "send",
40
+ summary: "Send a notification (uses your login — no key needed)",
41
+ usage: "[title]",
42
+ description: "Targets are what a production server sends to: --user ids from Push.register({ user }) and/or --to device addresses (`lecodes pn devices` lists both). The title may also be given as the first positional.",
43
+ flags: {
44
+ title: str("the notification title", { alias: "t", value: "<s>" }),
45
+ body: str("second line", { alias: "b", value: "<s>" }),
46
+ url: str("deep link (also arrives as app.launchUrl)", { value: "<s>" }),
47
+ user: str("target Push.register({ user }) ids, comma-separated", { alias: "u", value: "<ids>" }),
48
+ to: str("target addresses, comma-separated", { value: "<pa_…>" }),
49
+ badge: num("app icon badge"),
50
+ data: str("your JSON payload (≤ 2 KB)", { value: "<json>" }),
51
+ },
52
+ examples: ['lecodes pn send --title "Order shipped" --user u42', 'lecodes pn send --title "Hi" --to pa_abc --body "second line"'],
53
+ run: async ({ args, flags }) => {
54
+ const { apiUrl, token, manifest } = linkedProject()
55
+
56
+ const title = flags.title ?? args[0]
57
+ if (!title) throw new UsageError('A title is required: lecodes pn send --title "Order shipped" --user u42')
58
+
59
+ const users = listOf(flags.user)
60
+ const to = listOf(flags.to)
61
+ if (users.length === 0 && to.length === 0) {
62
+ throw new CliError("Nothing to send to — pass --user <id> (from Push.register({ user })) or --to <pa_…>. `lecodes pn devices` lists both.")
63
+ }
89
64
 
90
- const badgeRaw = flagStr(args, "badge")
91
- const badge = badgeRaw === undefined ? undefined : Number(badgeRaw)
92
- if (badge !== undefined && !Number.isInteger(badge)) throw new CliError(`--badge must be a whole number (got "${badgeRaw}")`)
65
+ if (flags.badge !== undefined && !Number.isInteger(flags.badge)) throw new CliError(`--badge must be a whole number (got "${flags.badge}")`)
93
66
 
94
- const dataRaw = flagStr(args, "data")
95
- let data: Record<string, unknown> | undefined
96
- if (dataRaw !== undefined) {
97
- try { data = JSON.parse(dataRaw) as Record<string, unknown> } catch { throw new CliError("--data must be a JSON object, e.g. --data '{\"orderId\":42}'") }
98
- if (!data || typeof data !== "object" || Array.isArray(data)) throw new CliError("--data must be a JSON object, e.g. --data '{\"orderId\":42}'")
99
- }
67
+ let data: Record<string, unknown> | undefined
68
+ if (flags.data !== undefined) {
69
+ try { data = JSON.parse(flags.data) as Record<string, unknown> } catch { throw new CliError("--data must be a JSON object, e.g. --data '{\"orderId\":42}'") }
70
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new CliError("--data must be a JSON object, e.g. --data '{\"orderId\":42}'")
71
+ }
100
72
 
101
- const payload: PushSendPayload = {
102
- title,
103
- body: flagStr(args, "body", "b"),
104
- url: flagStr(args, "url"),
105
- users: users.length ? users : undefined,
106
- to: to.length ? to : undefined,
107
- data,
108
- badge,
109
- }
73
+ const payload: PushSendPayload = {
74
+ title,
75
+ body: flags.body,
76
+ url: flags.url,
77
+ users: users.length ? users : undefined,
78
+ to: to.length ? to : undefined,
79
+ data,
80
+ badge: flags.badge,
81
+ }
110
82
 
111
- const result = await sendPush(ctx.apiUrl, ctx.token, ctx.uuid, payload)
83
+ const result = await sendPush(apiUrl, token, manifest.uuid, payload)
112
84
 
113
- if (result.sent > 0) success(`Sent to ${result.sent} device${result.sent === 1 ? "" : "s"}.`)
114
- if (result.failed.length === 0) {
115
- if (result.sent === 0) {
116
- warn("Matched 0 devices — nobody with that user id has registered. Run `lecodes pn devices` to see who has.")
85
+ if (result.sent > 0) success(`Sent to ${result.sent} device${result.sent === 1 ? "" : "s"}.`)
86
+ if (result.failed.length === 0) {
87
+ if (result.sent === 0) {
88
+ warn("Matched 0 devices — nobody with that user id has registered. Run `lecodes pn devices` to see who has.")
89
+ }
90
+ return
117
91
  }
118
- return
119
- }
120
- warn(`${result.failed.length} failed:`)
121
- for (const failure of result.failed) log(` ${c.dim(failure.address)} ${c.red(failure.reason)}`)
122
- }
92
+ warn(`${result.failed.length} failed:`)
93
+ for (const failure of result.failed) log(` ${c.dim(failure.address)} ${c.red(failure.reason)}`)
94
+ },
95
+ })
123
96
 
124
97
  /* -------------------------------------- devices --------------------------------------- */
125
98
 
@@ -128,178 +101,162 @@ const deviceLabel = (device: PushDevice): string => {
128
101
  return `${device.platform}${env}`
129
102
  }
130
103
 
131
- /** `lecodes pn devices [--user u42] [--forget pa_…]` — the registration ledger. Raw OS tokens
132
- * never leave the backend, so this is address + who/where/when only. --forget drops a dead row
133
- * (rotated token, wrong project) that would otherwise sit here forever and fail every send. */
134
- const pnDevices = async (args: Args) => {
135
- const ctx = context()
136
- const user = flagStr(args, "user", "u")
137
-
138
- const forget = flagStr(args, "forget")
139
- if (forget) {
140
- if (!flagBool(args, "yes", "y")) {
141
- warn(`Forgetting ${c.bold(forget)} — sends to this address stop immediately. The device gets a NEW address on its next Push.register().`)
142
- if (!await confirm("Forget it?")) return info("Cancelled.")
104
+ /** The registration ledger. Raw OS tokens never leave the backend, so this is address + who/where/
105
+ * when only. --forget drops a dead row (rotated token, wrong project) that would otherwise sit
106
+ * here forever and fail every send. */
107
+ const devices = defineCommand({
108
+ name: "devices",
109
+ aliases: ["device"],
110
+ summary: "List the devices that registered for this project (the 'why didn't it arrive?' command)",
111
+ description: "A device appears here after the app calls Push.register(). `lecodes dev` bundles are unattributed — register() rejects there; open a PUBLISHED project via QR.",
112
+ flags: {
113
+ user: str("only that user's devices", { alias: "u", value: "<id>" }),
114
+ forget: str("drop a dead registration", { value: "<pa_…>" }),
115
+ ...confirmFlag,
116
+ },
117
+ examples: ["lecodes pn devices", "lecodes pn devices --user u42", "lecodes pn devices --forget pa_abc -y"],
118
+ run: async ({ flags }) => {
119
+ const { apiUrl, token, manifest } = linkedProject()
120
+ const user = flags.user
121
+
122
+ if (flags.forget) {
123
+ if (!flags.yes) {
124
+ warn(`Forgetting ${c.bold(flags.forget)} — sends to this address stop immediately. The device gets a NEW address on its next Push.register().`)
125
+ if (!await confirm("Forget it?")) return info("Cancelled.")
126
+ }
127
+ await forgetPushDevice(apiUrl, token, manifest.uuid, flags.forget)
128
+ success(`Forgot ${c.bold(flags.forget)}.`)
129
+ return
143
130
  }
144
- await forgetPushDevice(ctx.apiUrl, ctx.token, ctx.uuid, forget)
145
- success(`Forgot ${c.bold(forget)}.`)
146
- return
147
- }
148
131
 
149
- const { devices, truncated } = await listPushDevices(ctx.apiUrl, ctx.token, ctx.uuid, user)
132
+ const { devices, truncated } = await listPushDevices(apiUrl, token, manifest.uuid, user)
150
133
 
151
- if (devices.length === 0) {
152
- info(user
153
- ? `No devices registered for user ${c.bold(user)} in ${c.bold(ctx.name)}.`
154
- : `No devices registered in ${c.bold(ctx.name)} yet.`)
155
- info(c.dim("A device appears here after the app calls Push.register(). Note that `lecodes dev`"))
156
- info(c.dim("bundles are unattributed — register() rejects there; open a PUBLISHED project via QR."))
157
- return
158
- }
134
+ if (devices.length === 0) {
135
+ info(user
136
+ ? `No devices registered for user ${c.bold(user)} in ${c.bold(manifest.name)}.`
137
+ : `No devices registered in ${c.bold(manifest.name)} yet.`)
138
+ info(c.dim("A device appears here after the app calls Push.register(). Note that `lecodes dev`"))
139
+ info(c.dim("bundles are unattributed — register() rejects there; open a PUBLISHED project via QR."))
140
+ return
141
+ }
159
142
 
160
- info(`${devices.length} device${devices.length === 1 ? "" : "s"} in ${c.bold(ctx.name)}${user ? ` for user ${c.bold(user)}` : ""}:`)
161
- table([
162
- [c.dim("ADDRESS"), c.dim("PLATFORM"), c.dim("USER"), c.dim("HOST"), c.dim("LAST SEEN")],
163
- ...devices.map(device => [
164
- device.disabledAt ? c.dim(device.address) : device.address,
165
- deviceLabel(device),
166
- device.user ?? c.dim("—"),
167
- device.hostApp,
168
- device.disabledAt ? c.red("disabled") : ago(device.lastSeenAt),
169
- ]),
170
- ])
171
- if (devices.some(d => d.disabledAt)) {
172
- info(c.dim("Disabled = the provider reported a dead token; re-registering from the device revives it."))
173
- }
174
- if (truncated) warn("Showing the 200 most recently seen — there are more.")
175
- }
143
+ info(`${devices.length} device${devices.length === 1 ? "" : "s"} in ${c.bold(manifest.name)}${user ? ` for user ${c.bold(user)}` : ""}:`)
144
+ table([
145
+ [c.dim("ADDRESS"), c.dim("PLATFORM"), c.dim("USER"), c.dim("HOST"), c.dim("LAST SEEN")],
146
+ ...devices.map(device => [
147
+ device.disabledAt ? c.dim(device.address) : device.address,
148
+ deviceLabel(device),
149
+ device.user ?? c.dim("—"),
150
+ device.hostApp,
151
+ device.disabledAt ? c.red("disabled") : relativeTime(device.lastSeenAt),
152
+ ]),
153
+ ])
154
+ if (devices.some(d => d.disabledAt)) {
155
+ info(c.dim("Disabled = the provider reported a dead token; re-registering from the device revives it."))
156
+ }
157
+ if (truncated) warn("Showing the 200 most recently seen — there are more.")
158
+ },
159
+ })
176
160
 
177
161
  /* ---------------------------------------- keys ---------------------------------------- */
178
162
 
179
- const pnKeysList = async (ctx: Context) => {
180
- const keys = await listPushKeys(ctx.apiUrl, ctx.token, ctx.uuid)
163
+ const listKeys = async ({ apiUrl, token, manifest }: LinkedProject): Promise<void> => {
164
+ const keys = await listPushKeys(apiUrl, token, manifest.uuid)
181
165
  if (keys.length === 0) {
182
- info(`No push keys in ${c.bold(ctx.name)}.`)
166
+ info(`No push keys in ${c.bold(manifest.name)}.`)
183
167
  info(c.dim("You only need one to send from your OWN server — `lecodes pn send` uses your login."))
184
168
  info(c.dim("Mint one with `lecodes pn keys new \"prod server\" --env`."))
185
169
  return
186
170
  }
187
- info(`${keys.length} push key${keys.length === 1 ? "" : "s"} in ${c.bold(ctx.name)}:`)
171
+ info(`${keys.length} push key${keys.length === 1 ? "" : "s"} in ${c.bold(manifest.name)}:`)
188
172
  table([
189
173
  [c.dim("ID"), c.dim("NAME"), c.dim("KEY"), c.dim("LAST USED")],
190
- ...keys.map(key => [String(key.id), key.name, `${key.prefix}${c.dim("…")}`, ago(key.lastUsedAt)]),
174
+ ...keys.map(key => [String(key.id), key.name, `${key.prefix}${c.dim("…")}`, relativeTime(key.lastUsedAt)]),
191
175
  ])
192
176
  }
193
177
 
194
- /** Append `LECODES_PUSH_KEY=…` to <root>/.env and make sure git can't swallow it. Returns the
195
- * paths touched. A key already in .env is REPLACED — rotating shouldn't leave two live lines. */
196
- const writeEnv = (root: string, key: string): string[] => {
197
- const touched: string[] = []
198
- const envPath = join(root, ".env")
199
- const line = `${ENV_VAR}=${key}`
200
-
201
- const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : ""
202
- if (new RegExp(`^${ENV_VAR}=`, "m").test(existing)) {
203
- writeFileSync(envPath, existing.replace(new RegExp(`^${ENV_VAR}=.*$`, "m"), line))
204
- } else {
205
- const separator = !existing || existing.endsWith("\n") ? "" : "\n"
206
- appendFileSync(envPath, `${separator}${line}\n`)
207
- }
208
- touched.push(".env")
209
-
210
- // A push key in a committed .env is the one way this command could make things worse.
211
- const ignorePath = join(root, ".gitignore")
212
- const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : ""
213
- if (!ignore.split("\n").some(l => l.trim() === ".env")) {
214
- const separator = !ignore || ignore.endsWith("\n") ? "" : "\n"
215
- appendFileSync(ignorePath, `${separator}.env\n`)
216
- touched.push(".gitignore")
217
- }
218
- return touched
219
- }
220
-
221
- /** `lecodes pn keys new [name] [--env]` — the server mints it; this is the ONLY time the
222
- * plaintext exists anywhere, so print it loudly (or bank it in .env with --env). */
223
- const pnKeysNew = async (ctx: Context, args: Args) => {
224
- const name = args._[2] ?? flagStr(args, "name")
225
- const created = await createPushKey(ctx.apiUrl, ctx.token, ctx.uuid, name)
226
-
227
- success(`Created push key ${c.bold(created.name)} (id ${created.id}).`)
228
- log()
229
- log(` ${c.bold(created.key)}`)
230
- log()
231
-
232
- if (flagBool(args, "env")) {
233
- const touched = writeEnv(ctx.root, created.key)
234
- success(`Wrote ${ENV_VAR} to ${touched.join(" + ")}.`)
235
- } else {
236
- warn("Copy it now — it is never shown again. Lose it and you mint a new one.")
237
- info(c.dim(` Tip: \`lecodes pn keys new --env\` writes it straight into .env as ${ENV_VAR}.`))
238
- }
239
-
240
- log()
241
- info(c.dim("Send from your server with:"))
242
- log(c.dim(` curl -X POST ${ctx.apiUrl}/api/projects/${ctx.uuid}/push/send \\`))
243
- log(c.dim(` -H "Authorization: Bearer $${ENV_VAR}" -H "Content-Type: application/json" \\`))
244
- log(c.dim(` -d '{"title":"Order shipped","users":["u42"]}'`))
245
- }
246
-
247
- /** `lecodes pn keys revoke <id>` — deletion, effective on the next request. Confirmed unless -y. */
248
- const pnKeysRevoke = async (ctx: Context, args: Args) => {
249
- const raw = args._[2]
250
- if (!raw) throw new CliError("Which key? `lecodes pn keys` lists their ids, then `lecodes pn keys revoke <id>`.")
251
- const id = Number(raw)
252
- if (!Number.isInteger(id)) throw new CliError(`"${raw}" is not a key id — run \`lecodes pn keys\` to see them.`)
253
-
254
- const keys = await listPushKeys(ctx.apiUrl, ctx.token, ctx.uuid)
255
- const key = keys.find(k => k.id === id)
256
- if (!key) throw new CliError(`No push key with id ${id} in ${ctx.name}.`)
257
-
258
- if (!flagBool(args, "yes", "y")) {
259
- warn(`Revoking ${c.bold(key.name)} (${key.prefix}…) — any server still using it stops sending immediately.`)
260
- if (!await confirm("Revoke it?")) return info("Cancelled.")
261
- }
262
- await revokePushKey(ctx.apiUrl, ctx.token, ctx.uuid, id)
263
- success(`Revoked push key ${c.bold(key.name)}.`)
264
- }
178
+ /** The server mints it; this is the ONLY time the plaintext exists anywhere, so print it loudly
179
+ * (or bank it in .env with --env — replacing a previous line, since rotating shouldn't leave two
180
+ * live keys — and make sure git can't swallow it). */
181
+ const keysNew = defineCommand({
182
+ name: "new",
183
+ aliases: ["create"],
184
+ summary: "Mint a push key for YOUR server (owner only; shown once)",
185
+ usage: "[name]",
186
+ description: "A push key is generated BY the server — you can't choose or recover one. It only sends for this project, which is why deploying it beats deploying a personal access token.",
187
+ flags: {
188
+ name: str("the key's name (or the first positional)", { value: "<s>" }),
189
+ env: bool(`write it to .env as ${ENV_VAR} (+ gitignore)`),
190
+ },
191
+ examples: ['lecodes pn keys new "prod server" --env'],
192
+ run: async ({ args, flags }) => {
193
+ const { root, apiUrl, token, manifest } = linkedProject()
194
+ const created = await createPushKey(apiUrl, token, manifest.uuid, args[0] ?? flags.name)
195
+
196
+ success(`Created push key ${c.bold(created.name)} (id ${created.id}).`)
197
+ log()
198
+ log(` ${c.bold(created.key)}`)
199
+ log()
200
+
201
+ if (flags.env) {
202
+ const touched = upsertDotEnv(root, { [ENV_VAR]: created.key })
203
+ success(`Wrote ${ENV_VAR} to ${touched.join(" + ")}.`)
204
+ } else {
205
+ warn("Copy it now — it is never shown again. Lose it and you mint a new one.")
206
+ info(c.dim(` Tip: \`lecodes pn keys new --env\` writes it straight into .env as ${ENV_VAR}.`))
207
+ }
265
208
 
266
- const pnKeys = async (args: Args) => {
267
- const ctx = context()
268
- const sub = args._[1]
269
- if (!sub || sub === "list") return pnKeysList(ctx)
270
- if (sub === "new" || sub === "create") return pnKeysNew(ctx, args)
271
- if (sub === "revoke" || sub === "delete" || sub === "rm") return pnKeysRevoke(ctx, args)
272
- throw new CliError(`Unknown keys command "${sub}" — use: lecodes pn keys [list | new [name] | revoke <id>]`)
273
- }
209
+ log()
210
+ info(c.dim("Send from your server with:"))
211
+ log(c.dim(` curl -X POST ${apiUrl}/api/projects/${manifest.uuid}/push/send \\`))
212
+ log(c.dim(` -H "Authorization: Bearer $${ENV_VAR}" -H "Content-Type: application/json" \\`))
213
+ log(c.dim(` -d '{"title":"Order shipped","users":["u42"]}'`))
214
+ },
215
+ })
216
+
217
+ /** Deletion, effective on the next request. Confirmed unless -y. */
218
+ const keysRevoke = defineCommand({
219
+ name: "revoke",
220
+ aliases: ["delete", "rm"],
221
+ summary: "Revoke a key — any server still using it stops sending immediately",
222
+ usage: "<id>",
223
+ flags: confirmFlag,
224
+ examples: ["lecodes pn keys revoke 3 -y"],
225
+ run: async ({ args, flags }) => {
226
+ const { apiUrl, token, manifest } = linkedProject()
227
+ const raw = args[0]
228
+ if (!raw) throw new UsageError("Which key? `lecodes pn keys` lists their ids, then `lecodes pn keys revoke <id>`.")
229
+ const id = Number(raw)
230
+ if (!Number.isInteger(id)) throw new CliError(`"${raw}" is not a key id — run \`lecodes pn keys\` to see them.`)
231
+
232
+ const keys = await listPushKeys(apiUrl, token, manifest.uuid)
233
+ const key = keys.find(k => k.id === id)
234
+ if (!key) throw new CliError(`No push key with id ${id} in ${manifest.name}.`)
235
+
236
+ if (!flags.yes) {
237
+ warn(`Revoking ${c.bold(key.name)} (${key.prefix}…) — any server still using it stops sending immediately.`)
238
+ if (!await confirm("Revoke it?")) return info("Cancelled.")
239
+ }
240
+ await revokePushKey(apiUrl, token, manifest.uuid, id)
241
+ success(`Revoked push key ${c.bold(key.name)}.`)
242
+ },
243
+ })
244
+
245
+ const keys = defineCommand({
246
+ name: "keys",
247
+ aliases: ["key"],
248
+ summary: "Push keys for YOUR server: list (bare), mint, revoke",
249
+ description: "Bare `lecodes pn keys` lists this project's keys. You only need one to send from your OWN server — `lecodes pn send` uses your login.",
250
+ commands: [keysNew, keysRevoke],
251
+ examples: ["lecodes pn keys", 'lecodes pn keys new "prod server" --env', "lecodes pn keys revoke 3"],
252
+ run: () => listKeys(linkedProject()),
253
+ })
274
254
 
275
255
  /* --------------------------------------- entry ---------------------------------------- */
276
256
 
277
- const PN_HELP = `${c.bold("lecodes pn")} — push notifications for this project.
278
-
279
- ${c.bold("Commands:")}
280
- pn send --title <s> Send a notification (uses your login — no key needed)
281
- --body <s> second line
282
- --url <s> deep link (also arrives as app.launchUrl)
283
- --user <ids> target Push.register({ user }) ids, comma-separated
284
- --to <pa_…> target addresses, comma-separated
285
- --badge <n> app icon badge
286
- --data <json> your JSON payload (≤ 2 KB)
287
- pn devices List the devices that registered for this project
288
- --user <id> only that user's devices
289
- --forget <pa_…> drop a dead registration (-y to skip confirm)
290
- pn keys List this project's push keys
291
- pn keys new [name] Mint one for YOUR server (owner only; shown once)
292
- --env write it to .env as ${ENV_VAR} (+ gitignore)
293
- pn keys revoke <id> Revoke a key (-y to skip the confirmation)
294
-
295
- ${c.dim("A push key is generated BY the server — you can't choose or recover one. It only sends")}
296
- ${c.dim("for this project, which is why deploying it beats deploying a personal access token.")}`
297
-
298
- export const pn = async (args: Args) => {
299
- const sub = args._[0]
300
- if (!sub || sub === "help") return log(PN_HELP)
301
- if (sub === "send") return pnSend(args)
302
- if (sub === "devices" || sub === "device") return pnDevices(args)
303
- if (sub === "keys" || sub === "key") return pnKeys(args)
304
- throw new CliError(`Unknown pn command "${sub}" — use: lecodes pn <send | devices | keys>. Run \`lecodes pn\` for the full list.`)
305
- }
257
+ export default defineCommand({
258
+ name: "pn",
259
+ summary: "Push notifications: send | devices | keys",
260
+ description: "Push notifications for the project in .lecodes/manifest.json. `send` uses your login (no push key needed to test); `devices` is the registration ledger; `keys` mints the `lecodes_pk_…` keys a production server deploys.",
261
+ commands: [send, devices, keys],
262
+ })
@@ -1,69 +1,65 @@
1
1
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
2
2
  import { dirname, join } from "node:path"
3
- import { downloadBlob, getCommits, getProject, type Project, type ProjectAsset } from "../api"
4
- import { loadConfig, requireApiUrl, requireToken } from "../config"
5
- import { findProjectRoot, readManifest, writeManifest, type FileKind } from "../manifest"
6
- import { buildPaths, materializeAssets } from "../project"
7
- import { materializeTypes, writeTsconfig } from "../types"
8
- import { diffLocal, hasChanges, scanProject } from "../localFiles"
9
- import { CliError, c, info, success, progress, endProgress, flagBool, sha256, toKey, type Args } from "../util"
3
+ import { CliError, bool, c, defineCommand, endProgress, info, progress, success } from "../cli"
4
+ import { downloadBlob, getCommits, getProject, type Project, type ProjectAsset } from "../platform/api"
5
+ import { diffLocal, hasChanges, scanProject } from "../project/localFiles"
6
+ import { writeManifest, type FileKind, type Manifest } from "../project/manifest"
7
+ import { buildPaths, materializeAssets } from "../project/materialize"
8
+ import { sha256, toKey } from "../project/paths"
9
+ import { materializeTypes, writeTsconfig } from "../project/types"
10
+ import { linkedProject } from "./shared"
10
11
 
11
12
  /* `lecodes pull` — overwrite local files with the project's current remote state. Refuses to
12
13
  * clobber uncommitted local edits unless --force. Given file/folder paths, pulls only those (a
13
14
  * targeted "take the server's version of this file" for manual conflict resolution). */
14
- export const pull = async (args: Args) => {
15
- const root = findProjectRoot(process.cwd())
16
- const manifest = readManifest(root)
17
- const config = loadConfig()
18
- const apiUrl = requireApiUrl(config)
19
- const token = requireToken(config)
15
+ export default defineCommand({
16
+ name: "pull",
17
+ summary: "Overwrite local files with the project's current server state",
18
+ usage: "[path…]",
19
+ description: "With paths: pull only those files / folders and keep every other local edit (no dirty check; you still owe a full pull or --force before a clean push).",
20
+ flags: {
21
+ force: bool("discard uncommitted local changes"),
22
+ "no-types": bool("skip the .d.ts / tsconfig refresh"),
23
+ },
24
+ run: async ({ args, flags }) => {
25
+ const { root, manifest, apiUrl, token } = linkedProject()
20
26
 
21
- // Targeted pull of specific paths — no dirty guard (the point is to merge into local edits) and
22
- // baseHeadId is left as-is (you still owe a full pull/--force before a clean push).
23
- if (args._.length > 0) {
24
- const project = await getProject(apiUrl, token, manifest.uuid)
25
- await pullPaths(root, manifest, project, args._.map(toKey), apiUrl, token)
26
- return
27
- }
28
-
29
- if (!flagBool(args, "force") && hasChanges(diffLocal(manifest, scanProject(root, manifest)))) {
30
- throw new CliError("You have local changes. Push them first, or run `lecodes pull --force` to discard them.")
31
- }
27
+ if (args.length > 0) {
28
+ const project = await getProject(apiUrl, token, manifest.uuid)
29
+ await pullPaths(root, manifest, project, args.map(toKey), apiUrl, token)
30
+ return
31
+ }
32
+ if (!flags.force && hasChanges(diffLocal(manifest, scanProject(root, manifest)))) {
33
+ throw new CliError("You have local changes. Push them first, or run `lecodes pull --force` to discard them.")
34
+ }
32
35
 
33
- info(c.dim("Fetching project…"))
34
- const project = await getProject(apiUrl, token, manifest.uuid)
36
+ info(c.dim("Fetching project…"))
37
+ const project = await getProject(apiUrl, token, manifest.uuid)
35
38
 
36
- // Remove tracked files that no longer exist remotely (untracked local files are left alone).
37
- const remotePaths = new Set([...buildPaths(project.assets).entries()]
38
- .filter(([id]) => project.assets.find(a => a.id === id)?.type !== "folder")
39
- .map(([, path]) => path))
40
- for (const path of Object.keys(manifest.files)) {
41
- if (!remotePaths.has(path)) {
39
+ // Remove tracked files that no longer exist remotely (untracked local files are left alone).
40
+ const remotePaths = new Set([...buildPaths(project.assets).entries()]
41
+ .filter(([id]) => project.assets.find(a => a.id === id)?.type !== "folder")
42
+ .map(([, path]) => path))
43
+ for (const path of Object.keys(manifest.files)) {
44
+ if (remotePaths.has(path)) continue
42
45
  const abs = join(root, ...path.replace(/^\//, "").split("/"))
43
46
  if (existsSync(abs)) rmSync(abs)
44
47
  }
45
- }
46
-
47
- const files = await materializeAssets(root, project, apiUrl, token, progress)
48
- endProgress()
49
- if (!flagBool(args, "no-types")) {
50
- await materializeTypes(root, apiUrl)
51
- writeTsconfig(root)
52
- }
53
48
 
54
- const commits = await getCommits(apiUrl, token, manifest.uuid)
55
- writeManifest(root, { ...manifest, name: project.name, baseHeadId: commits.headId, files })
49
+ const files = await materializeAssets(root, project, apiUrl, token, progress)
50
+ endProgress()
51
+ if (!flags["no-types"]) { await materializeTypes(root, apiUrl); writeTsconfig(root) }
56
52
 
57
- success(`Pulled latest into ${c.bold(manifest.name)}.`)
58
- }
53
+ const commits = await getCommits(apiUrl, token, manifest.uuid)
54
+ writeManifest(root, { ...manifest, name: project.name, baseHeadId: commits.headId, files })
55
+ success(`Pulled latest into ${c.bold(manifest.name)}.`)
56
+ },
57
+ })
59
58
 
60
59
  const kindOf = (type: ProjectAsset["type"]): FileKind => (type === "file" ? "resource" : type as FileKind)
61
60
 
62
61
  /** Pull only the given paths (a file, or every file under a folder) from the server, in place. */
63
- const pullPaths = async (
64
- root: string, manifest: ReturnType<typeof readManifest>, project: Project,
65
- paths: string[], apiUrl: string, token: string,
66
- ) => {
62
+ const pullPaths = async (root: string, manifest: Manifest, project: Project, paths: string[], apiUrl: string, token: string): Promise<void> => {
67
63
  const pathById = buildPaths(project.assets)
68
64
  const byPath = new Map<string, ProjectAsset>()
69
65
  for (const asset of project.assets) byPath.set(pathById.get(asset.id)!, asset)
@@ -73,14 +69,11 @@ const pullPaths = async (
73
69
  for (const path of paths) {
74
70
  const asset = byPath.get(path)
75
71
  if (!asset) throw new CliError(`Not found on the server: ${path}`)
76
- if (asset.type === "folder") {
77
- const prefix = path + "/"
78
- const under = project.assets.filter(a => a.type !== "folder" && pathById.get(a.id)!.startsWith(prefix))
79
- if (under.length === 0) throw new CliError(`Folder is empty on the server: ${path}`)
80
- targets.push(...under)
81
- } else {
82
- targets.push(asset)
83
- }
72
+ if (asset.type !== "folder") { targets.push(asset); continue }
73
+ const prefix = path + "/"
74
+ const under = project.assets.filter(a => a.type !== "folder" && pathById.get(a.id)!.startsWith(prefix))
75
+ if (under.length === 0) throw new CliError(`Folder is empty on the server: ${path}`)
76
+ targets.push(...under)
84
77
  }
85
78
 
86
79
  const files = { ...manifest.files }