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.
- package/README.md +56 -57
- package/dist/index.js +5920 -5022
- package/package.json +9 -7
- package/runtime/materials/decal-relief.mat +12 -1
- package/runtime/materials/decal.mat +17 -3
- package/runtime/materials/lightmap-baked-lite.mat +6 -1
- package/runtime/materials/lightmap-baked.mat +6 -1
- package/runtime/materials/lightmap.mat +3 -0
- package/runtime/materials/particles-quad.mat +17 -9
- package/runtime/materials/particles.mat +17 -9
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/assets/createViewerLite-DHqTFhWB.js +866 -0
- package/runtime/web/assets/{index-BTB5KV9x.js → index-Q06c6oHx.js} +1 -1
- package/runtime/web/embed.js +2 -2
- package/src/api.ts +1 -302
- package/src/cli/args.ts +37 -0
- package/src/cli/command.ts +113 -0
- package/src/cli/errors.ts +13 -0
- package/src/cli/help.ts +133 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/output.ts +102 -0
- package/src/cli/run.ts +106 -0
- package/src/commands/{appAndroid.ts → app/android.ts} +451 -42
- package/src/commands/{appDesktop.ts → app/desktop.ts} +63 -32
- package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +24 -18
- package/src/commands/{appIcon.ts → app/icon.ts} +2 -2
- package/src/commands/{app.ts → app/index.ts} +397 -289
- package/src/commands/{appShared.ts → app/shared.ts} +30 -39
- package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +62 -28
- package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
- package/src/commands/{appTemplates.ts → app/templates/ios.ts} +1 -1
- package/src/commands/assets.ts +23 -21
- package/src/commands/clone.ts +48 -51
- package/src/commands/compile.ts +62 -88
- package/src/commands/create.ts +46 -47
- package/src/commands/design/comments.ts +361 -0
- package/src/commands/design/context.ts +101 -0
- package/src/commands/design/index.ts +391 -0
- package/src/commands/design/snapshot.ts +134 -0
- package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
- package/src/commands/desktop.ts +90 -82
- package/src/commands/dev.ts +201 -176
- package/src/commands/diff.ts +39 -51
- package/src/commands/index.ts +60 -0
- package/src/commands/{init.ts → init/index.ts} +199 -191
- package/src/commands/install.ts +80 -67
- package/src/commands/lightmap.ts +203 -171
- package/src/commands/link.ts +87 -94
- package/src/commands/login.ts +25 -23
- package/src/commands/navmesh.ts +168 -138
- package/src/commands/pn.ts +201 -244
- package/src/commands/pull.ts +49 -56
- package/src/commands/push.ts +48 -54
- package/src/commands/render.ts +158 -99
- package/src/commands/scene.ts +58 -55
- package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
- package/src/commands/{shadersNew.ts → shaders/new.ts} +2 -2
- package/src/commands/shared.ts +79 -0
- package/src/commands/status.ts +22 -26
- package/src/commands/test.ts +398 -371
- package/src/commands/thumbs.ts +185 -175
- package/src/commands/update/index.ts +226 -0
- package/src/commands/{types.ts → update/types.ts} +31 -22
- package/src/compile/collect.ts +5 -5
- package/src/compile/collectLocal.ts +3 -3
- package/src/compile/designCompile.ts +3 -3
- package/src/compile/headlessBundle.ts +133 -129
- package/src/compile/projectCompile.ts +2 -2
- package/src/compile/sceneCompile.ts +10 -8
- package/src/compile/screenEntry.ts +123 -127
- package/src/compile/shaders.ts +3 -3
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/devServer.ts +3 -3
- package/src/dev/webRunner.ts +1 -1
- package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +1 -1
- package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +6 -5
- package/src/{desktopScript.ts → hosts/desktopScript.ts} +1 -1
- package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
- package/src/{matcTool.ts → hosts/matcTool.ts} +1 -1
- package/src/{peerInstall.ts → hosts/peerInstall.ts} +2 -2
- package/src/{releases.ts → hosts/releases.ts} +1 -1
- package/src/index.ts +32 -480
- package/src/platform/api.ts +302 -0
- package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
- package/src/{config.ts → platform/config.ts} +1 -1
- package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
- package/src/{projectEnv.ts → project/env.ts} +33 -2
- package/src/{localFiles.ts → project/localFiles.ts} +1 -1
- package/src/{manifest.ts → project/manifest.ts} +1 -1
- package/src/{project.ts → project/materialize.ts} +2 -2
- package/src/project/paths.ts +20 -0
- package/src/{textDiff.ts → project/textDiff.ts} +1 -1
- package/src/{types.ts → project/types.ts} +0 -0
- package/runtime/web/assets/createViewerLite-C_bkKXLg.js +0 -852
- package/src/commands/design.ts +0 -846
- package/src/commands/update.ts +0 -211
- package/src/util.ts +0 -146
- /package/src/commands/{projectTemplates.ts → init/templates.ts} +0 -0
- /package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +0 -0
- /package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +0 -0
- /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
- /package/src/{lecodes-renderer.d.ts → declarations/lecodes-renderer.d.ts} +0 -0
- /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
- /package/src/{peers.ts → hosts/peers.ts} +0 -0
- /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
- /package/src/{ignore.ts → project/ignore.ts} +0 -0
package/src/commands/pn.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
-
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
if (!title) throw new CliError('A title is required: lecodes pn send --title "Order shipped" --user u42')
|
|
34
|
+
/* ---------------------------------------- send ---------------------------------------- */
|
|
83
35
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
83
|
+
const result = await sendPush(apiUrl, token, manifest.uuid, payload)
|
|
112
84
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
132
|
+
const { devices, truncated } = await listPushDevices(apiUrl, token, manifest.uuid, user)
|
|
150
133
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
180
|
-
const keys = await listPushKeys(
|
|
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(
|
|
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(
|
|
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("…")}`,
|
|
174
|
+
...keys.map(key => [String(key.id), key.name, `${key.prefix}${c.dim("…")}`, relativeTime(key.lastUsedAt)]),
|
|
191
175
|
])
|
|
192
176
|
}
|
|
193
177
|
|
|
194
|
-
/**
|
|
195
|
-
*
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
+
})
|
package/src/commands/pull.ts
CHANGED
|
@@ -1,69 +1,65 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
|
2
2
|
import { dirname, join } from "node:path"
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
34
|
-
|
|
36
|
+
info(c.dim("Fetching project…"))
|
|
37
|
+
const project = await getProject(apiUrl, token, manifest.uuid)
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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 }
|