lecodes-cli 0.10.2 → 0.10.4

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/dist/index.js CHANGED
@@ -2129,7 +2129,7 @@ var require_main = __commonJS((exports, module) => {
2129
2129
  // package.json
2130
2130
  var package_default = {
2131
2131
  name: "lecodes-cli",
2132
- version: "0.10.2",
2132
+ version: "0.10.4",
2133
2133
  dependencies: {
2134
2134
  "@letary/chisel": "^0.6.0"
2135
2135
  },
@@ -2210,8 +2210,10 @@ var c = {
2210
2210
  yellow: (s) => paint("33", s),
2211
2211
  cyan: (s) => paint("36", s)
2212
2212
  };
2213
- var log = (msg = "") => process.stdout.write(msg + `
2213
+ var log = (msg = "") => {
2214
+ process.stdout.write(msg + `
2214
2215
  `);
2216
+ };
2215
2217
  var info = (msg) => log(msg);
2216
2218
  var success = (msg) => log(c.green("✓ ") + msg);
2217
2219
  var warn = (msg) => log(c.yellow("! ") + msg);
@@ -2523,6 +2525,12 @@ var sync = (apiUrl, token, uuid, payload) => apiRequest(apiUrl, `/projects/${uui
2523
2525
  var publishProject = (apiUrl, token, uuid) => apiRequest(apiUrl, `/projects/${uuid}/publish`, { token, body: {} });
2524
2526
  var enableDesignShare = (apiUrl, token, uuid) => apiRequest(apiUrl, `/projects/${uuid}/design/share`, { token, body: {} });
2525
2527
  var disableDesignShare = (apiUrl, token, uuid) => apiRequest(apiUrl, `/projects/${uuid}/design/share`, { token, method: "DELETE" });
2528
+ var sendPush = (apiUrl, token, uuid, payload) => apiRequest(apiUrl, `/projects/${uuid}/push/send`, { token, body: payload });
2529
+ var listPushDevices = (apiUrl, token, uuid, user) => apiRequest(apiUrl, `/projects/${uuid}/push/registrations${user ? `?user=${encodeURIComponent(user)}` : ""}`, { token });
2530
+ var forgetPushDevice = (apiUrl, token, uuid, address) => apiRequest(apiUrl, `/projects/${uuid}/push/registrations/${encodeURIComponent(address)}`, { token, method: "DELETE" });
2531
+ var listPushKeys = (apiUrl, token, uuid) => apiRequest(apiUrl, `/projects/${uuid}/push/keys`, { token });
2532
+ var createPushKey = (apiUrl, token, uuid, name) => apiRequest(apiUrl, `/projects/${uuid}/push/keys`, { token, body: { name } });
2533
+ var revokePushKey = (apiUrl, token, uuid, id) => apiRequest(apiUrl, `/projects/${uuid}/push/keys/${id}`, { token, method: "DELETE" });
2526
2534
  var downloadBlob = async (apiUrl, src) => {
2527
2535
  const resp = await fetch(`${apiUrl}${src}`);
2528
2536
  if (!resp.ok)
@@ -3047,6 +3055,7 @@ var pbxprojTemplate = ({ displayName, bundleId, teamId, version, buildNumber })
3047
3055
  const target = `
3048
3056
  ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
3049
3057
  ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
3058
+ CODE_SIGN_ENTITLEMENTS = "App/App.entitlements";
3050
3059
  CODE_SIGN_STYLE = Automatic;
3051
3060
  CURRENT_PROJECT_VERSION = ${buildNumber};
3052
3061
  DEVELOPMENT_TEAM = "${pbxStr(teamId)}";
@@ -3154,6 +3163,7 @@ var pbxprojTemplate = ({ displayName, bundleId, teamId, version, buildNumber })
3154
3163
  B73550792FEBAC100005B4CC /* Exceptions for "App" folder in "App" target */ = {
3155
3164
  isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
3156
3165
  membershipExceptions = (
3166
+ App.entitlements,
3157
3167
  Info.plist,
3158
3168
  );
3159
3169
  target = B73550662FEBAC0F0005B4CC /* App */;
@@ -3433,14 +3443,30 @@ var APP_DELEGATE = `//
3433
3443
  //
3434
3444
 
3435
3445
  import UIKit
3446
+ import LeCodesRuntime
3436
3447
 
3437
3448
  @main
3438
3449
  class AppDelegate: UIResponder, UIApplicationDelegate {
3439
3450
 
3440
3451
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
3452
+ // Plugin hook surface: installs the notification-center delegate (must be in
3453
+ // place before the app finishes launching) and buffers launch-time events until
3454
+ // a plugin that needs them registers. Inert if no installed plugin consumes it.
3455
+ LeCodesAppHooks.install()
3441
3456
  return true
3442
3457
  }
3443
3458
 
3459
+ // APNs token callbacks → the plugin hook surface (consumed by the push plugin).
3460
+ func application(_ application: UIApplication,
3461
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
3462
+ LeCodesAppHooks.remoteNotificationsToken(deviceToken)
3463
+ }
3464
+
3465
+ func application(_ application: UIApplication,
3466
+ didFailToRegisterForRemoteNotificationsWithError error: Error) {
3467
+ LeCodesAppHooks.remoteNotificationsError(error)
3468
+ }
3469
+
3444
3470
  // MARK: UISceneSession Lifecycle
3445
3471
 
3446
3472
  func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
@@ -3454,6 +3480,7 @@ var SCENE_DELEGATE = `//
3454
3480
  //
3455
3481
 
3456
3482
  import UIKit
3483
+ import LeCodesRuntime
3457
3484
 
3458
3485
  class SceneDelegate: UIResponder, UIWindowSceneDelegate {
3459
3486
 
@@ -3466,6 +3493,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
3466
3493
  ) {
3467
3494
  guard let windowScene = (scene as? UIWindowScene) else { return }
3468
3495
 
3496
+ // Cold-started by a notification tap: buffered by the hooks; a plugin that
3497
+ // consumes it (push) drains the buffer when it registers — before the JS world
3498
+ // boots, because makeKeyAndVisible() runs registerBundledPlugins via viewDidLoad.
3499
+ if let response = connectionOptions.notificationResponse {
3500
+ LeCodesAppHooks.coldStartNotification(response)
3501
+ }
3502
+
3469
3503
  let window = UIWindow(windowScene: windowScene)
3470
3504
  window.rootViewController = ViewController()
3471
3505
  self.window = window
@@ -3519,6 +3553,47 @@ class ViewController: LeCodesViewController {
3519
3553
  }
3520
3554
  }
3521
3555
  `;
3556
+ var plistValue = (value, indent) => {
3557
+ if (typeof value === "string")
3558
+ return `<string>${value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</string>`;
3559
+ if (typeof value === "boolean")
3560
+ return value ? "<true/>" : "<false/>";
3561
+ if (typeof value === "number")
3562
+ return Number.isInteger(value) ? `<integer>${value}</integer>` : `<real>${value}</real>`;
3563
+ if (Array.isArray(value)) {
3564
+ if (value.length === 0)
3565
+ return "<array/>";
3566
+ return `<array>
3567
+ ${value.map((v) => `${indent} ${plistValue(v, indent + "\t")}`).join(`
3568
+ `)}
3569
+ ${indent}</array>`;
3570
+ }
3571
+ if (value && typeof value === "object") {
3572
+ const entries = Object.entries(value);
3573
+ if (entries.length === 0)
3574
+ return "<dict/>";
3575
+ return `<dict>
3576
+ ${entries.map(([k, v]) => `${indent} <key>${k}</key>
3577
+ ${indent} ${plistValue(v, indent + "\t")}`).join(`
3578
+ `)}
3579
+ ${indent}</dict>`;
3580
+ }
3581
+ return "<string></string>";
3582
+ };
3583
+ var entitlementsTemplate = (entries) => {
3584
+ const keys = Object.keys(entries).sort();
3585
+ const body = keys.length === 0 ? "" : keys.map((k) => ` <key>${k}</key>
3586
+ ${plistValue(entries[k], "\t")}`).join(`
3587
+ `) + `
3588
+ `;
3589
+ return `<?xml version="1.0" encoding="UTF-8"?>
3590
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3591
+ <plist version="1.0">
3592
+ <dict>
3593
+ ${body}</dict>
3594
+ </plist>
3595
+ `;
3596
+ };
3522
3597
  var infoPlistTemplate = (pluginKeys, updateUrl) => {
3523
3598
  const xml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3524
3599
  const keys = Object.keys(pluginKeys).sort();
@@ -3684,12 +3759,17 @@ let package = Package(
3684
3759
  )
3685
3760
  `;
3686
3761
  };
3687
- var runtimeSwiftTemplate = (plugins) => {
3762
+ var runtimeSwiftTemplate = (plugins, projectUuid) => {
3688
3763
  const imports = plugins.map((p) => `import ${p.module}
3689
3764
  `).join("");
3690
- const calls = plugins.length === 0 ? `
3691
- ` : plugins.map((p) => ` ${p.register}.register(in: engine)
3765
+ const identity = !projectUuid ? "" : ` // World identity: this shell IS the project — the engine attributes the boot world so
3766
+ // identity-gated services (push registration) see the app's uuid from its first
3767
+ // synchronous statement. Baked from .lecodes/manifest.json by \`lecodes app sync\`.
3768
+ engine.bootProjectUuid = "${projectUuid}"
3769
+ `;
3770
+ const calls = plugins.map((p) => ` ${p.register}.register(in: engine)
3692
3771
  `).join("");
3772
+ const body = identity + calls;
3693
3773
  return `//
3694
3774
  // LeCodesRuntime.swift
3695
3775
  // GENERATED by lecodes — regenerated by \`lecodes app sync\`; do not edit.
@@ -3701,7 +3781,8 @@ var runtimeSwiftTemplate = (plugins) => {
3701
3781
  @_exported import LeCodesSDK
3702
3782
  ${imports}
3703
3783
  public func registerBundledPlugins(in engine: LeCodesEngine) {
3704
- ${calls}}
3784
+ ${body === "" ? `
3785
+ ` : body}}
3705
3786
  `;
3706
3787
  };
3707
3788
  var IOS_README = `# ios/ — native Xcode shell
@@ -3754,6 +3835,7 @@ CLI-owned — regenerated on every sync, hand edits are overwritten:
3754
3835
  the app runs fully offline. Globs in app.json \`"resources": {"remote": [...]}\` opt heavy
3755
3836
  assets (videos, big models) back into streaming from the server instead.
3756
3837
  - \`App/Info.plist\` — template + the installed plugins' usage keys
3838
+ - \`App/App.entitlements\` — the capabilities the installed plugins declare (e.g. push)
3757
3839
  - \`LeCodesRuntime/\` — local Swift package: the LeCodesSDK dependency, vendored plugin
3758
3840
  sources (Sources/<Module>/), and the registerBundledPlugins entry point
3759
3841
  - the version values inside \`App.xcodeproj/project.pbxproj\` (from app.json)
@@ -3788,9 +3870,12 @@ buildNumber) — edit it there, then re-run \`lecodes app sync\`. Plugins are ma
3788
3870
  var SDK_REPO = "letary/lecodes-ios-sdk";
3789
3871
  var SDK_MIN_VERSION = "1.2.0";
3790
3872
  var SDK_VARIANTS = ["core", "2d", "3d", "full"];
3873
+ var SDK_IDENTITY_VERSION = "1.6.0";
3791
3874
  var FIRST_PARTY_PLUGINS = {
3792
3875
  camera: "letary/lecodes-plugins",
3793
- "qr-scanner": "letary/lecodes-plugins"
3876
+ "qr-scanner": "letary/lecodes-plugins",
3877
+ geolocation: "letary/lecodes-plugins",
3878
+ push: "letary/lecodes-plugins"
3794
3879
  };
3795
3880
  var readJson = (path) => {
3796
3881
  if (!existsSync9(path))
@@ -3994,6 +4079,7 @@ ios/
3994
4079
  write("App/SceneDelegate.swift", SCENE_DELEGATE);
3995
4080
  write("App/ViewController.swift", VIEW_CONTROLLER);
3996
4081
  write("App/Info.plist", infoPlistTemplate({}));
4082
+ write("App/App.entitlements", entitlementsTemplate({}));
3997
4083
  write("App/Base.lproj/LaunchScreen.storyboard", LAUNCH_STORYBOARD);
3998
4084
  write("App/Assets.xcassets/Contents.json", ASSETS_ROOT_JSON);
3999
4085
  write("App/Assets.xcassets/AccentColor.colorset/Contents.json", ACCENT_COLOR_JSON);
@@ -4096,6 +4182,7 @@ var appSync = async (_args) => {
4096
4182
  const pluginSpecs = Object.entries(config.plugins ?? {});
4097
4183
  const runtimePlugins = [];
4098
4184
  const infoValues = {};
4185
+ const entitlementValues = {};
4099
4186
  const newLock = {};
4100
4187
  const sourcesDir = join11(iosDir, "LeCodesRuntime", "Sources");
4101
4188
  for (const [id, spec] of pluginSpecs) {
@@ -4112,11 +4199,14 @@ var appSync = async (_args) => {
4112
4199
  rmSync(dest, { recursive: true, force: true });
4113
4200
  cpSync(src, dest, { recursive: true });
4114
4201
  if (ios.minSdk && VERSION_RE.test(ios.minSdk) && cmpVersion(sdkVersion, ios.minSdk) < 0) {
4115
- warn(`Plugin "${id}" needs lecodes-ios-sdk ${ios.minSdk} but the shell links ${sdkVersion} — run \`lecodes app update\` (or set app.json ios.sdk).`);
4202
+ throw new CliError(`Plugin "${id}" needs lecodes-ios-sdk ${ios.minSdk} but the shell links ${sdkVersion} — run \`lecodes app update\` (or set app.json ios.sdk).`);
4116
4203
  }
4117
4204
  for (const [key, value] of Object.entries(ios.infoPlist ?? {})) {
4118
4205
  (infoValues[key] ??= []).push(value);
4119
4206
  }
4207
+ for (const [key, value] of Object.entries(ios.entitlements ?? {})) {
4208
+ (entitlementValues[key] ??= []).push(value);
4209
+ }
4120
4210
  runtimePlugins.push({ id, module, register });
4121
4211
  newLock[id] = plugin.lock;
4122
4212
  log(`${c.green("plugin")} ${id}@${plugin.lock.version} (${plugin.lock.tag} → Sources/${module})`);
@@ -4186,9 +4276,21 @@ var appSync = async (_args) => {
4186
4276
  const streamed = resources.filter((r) => r.remote);
4187
4277
  if (streamed.length > 0)
4188
4278
  log(`${c.green("remote")} ${streamed.length} resource(s) stay on the server (resources.remote): ${streamed.map((r) => r.path).join(", ")}`);
4279
+ let shellUuid;
4280
+ try {
4281
+ shellUuid = readManifest(root).uuid;
4282
+ } catch {}
4283
+ if (shellUuid && cmpVersion(sdkVersion, SDK_IDENTITY_VERSION) < 0) {
4284
+ if (runtimePlugins.some((p) => p.id === "push")) {
4285
+ warn(`push needs the shell's world identity, which lecodes-ios-sdk ${SDK_IDENTITY_VERSION}+ carries — this shell links ${sdkVersion}, so Push.register() will reject "unavailable". Run \`lecodes app update\` when ${SDK_IDENTITY_VERSION} is out.`);
4286
+ }
4287
+ shellUuid = undefined;
4288
+ }
4289
+ if (shellUuid)
4290
+ log(`${c.green("identity")} project ${shellUuid} baked into the shell (world identity)`);
4189
4291
  writeFileSync7(join11(iosDir, "README.md"), IOS_README);
4190
4292
  writeFileSync7(join11(iosDir, "LeCodesRuntime", "Package.swift"), runtimePackageTemplate(sdkVersion, variant, runtimePlugins));
4191
- writeFileSync7(join11(sourcesDir, "LeCodesRuntime", "LeCodesRuntime.swift"), runtimeSwiftTemplate(runtimePlugins));
4293
+ writeFileSync7(join11(sourcesDir, "LeCodesRuntime", "LeCodesRuntime.swift"), runtimeSwiftTemplate(runtimePlugins, shellUuid));
4192
4294
  const mergedKeys = {};
4193
4295
  for (const [key, values] of Object.entries(infoValues))
4194
4296
  mergedKeys[key] = [...new Set(values)].join(" ");
@@ -4199,7 +4301,43 @@ var appSync = async (_args) => {
4199
4301
  if (pluginSpecs.length > 0 || existsSync9(lockPath))
4200
4302
  writeJson(lockPath, { plugins: newLock });
4201
4303
  const pbxPath = join11(iosDir, "App.xcodeproj", "project.pbxproj");
4202
- const pbx = readFileSync10(pbxPath, "utf8").replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${config.version ?? "1.0.0"};`).replace(/CURRENT_PROJECT_VERSION = [^;]+;/g, `CURRENT_PROJECT_VERSION = ${config.ios.buildNumber ?? 1};`);
4304
+ let pbx = readFileSync10(pbxPath, "utf8").replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${config.version ?? "1.0.0"};`).replace(/CURRENT_PROJECT_VERSION = [^;]+;/g, `CURRENT_PROJECT_VERSION = ${config.ios.buildNumber ?? 1};`);
4305
+ const mergedEntitlements = {};
4306
+ for (const [key, values] of Object.entries(entitlementValues)) {
4307
+ if (values.every((v) => Array.isArray(v))) {
4308
+ const seen = new Set;
4309
+ mergedEntitlements[key] = values.flat().filter((v) => {
4310
+ const s = JSON.stringify(v);
4311
+ if (seen.has(s))
4312
+ return false;
4313
+ seen.add(s);
4314
+ return true;
4315
+ });
4316
+ } else {
4317
+ if (new Set(values.map((v) => JSON.stringify(v))).size > 1) {
4318
+ warn(`entitlement ${key}: plugins declare conflicting values — keeping ${JSON.stringify(values[0])}`);
4319
+ }
4320
+ mergedEntitlements[key] = values[0];
4321
+ }
4322
+ }
4323
+ const hasEntitlements = Object.keys(mergedEntitlements).length > 0;
4324
+ if (pbx.includes("CODE_SIGN_ENTITLEMENTS") || hasEntitlements) {
4325
+ writeFileSync7(join11(iosDir, "App", "App.entitlements"), entitlementsTemplate(mergedEntitlements));
4326
+ if (hasEntitlements)
4327
+ log(`${c.green("caps")} App/App.entitlements: ${Object.keys(mergedEntitlements).sort().join(", ")}`);
4328
+ if (!pbx.includes("CODE_SIGN_ENTITLEMENTS")) {
4329
+ const patched = pbx.replace(/^(\s*)CODE_SIGN_STYLE = /gm, `$1CODE_SIGN_ENTITLEMENTS = "App/App.entitlements";
4330
+ $1CODE_SIGN_STYLE = `);
4331
+ if (patched === pbx) {
4332
+ warn(`couldn't wire App.entitlements into the pbxproj — add CODE_SIGN_ENTITLEMENTS = "App/App.entitlements" to the App target's build settings in Xcode.`);
4333
+ } else {
4334
+ pbx = patched.replace(/^(\s*)membershipExceptions = \(\n(\s*)Info\.plist,/m, `$1membershipExceptions = (
4335
+ $2App.entitlements,
4336
+ $2Info.plist,`);
4337
+ log(`${c.green("updated")} App.xcodeproj: CODE_SIGN_ENTITLEMENTS → App/App.entitlements`);
4338
+ }
4339
+ }
4340
+ }
4203
4341
  writeFileSync7(pbxPath, pbx);
4204
4342
  log("");
4205
4343
  success(`Shell synced: app.js + ${runtimePlugins.length} plugin(s), SDK ${sdkVersion}, variant ${variant}, v${config.version ?? "1.0.0"} (${config.ios.buildNumber ?? 1}).`);
@@ -4426,6 +4564,15 @@ var appDoctor = async (_args) => {
4426
4564
  const wanted = Object.keys(config.plugins ?? {});
4427
4565
  const inSync = wanted.every((id) => lock[id]) && Object.keys(lock).every((id) => wanted.includes(id));
4428
4566
  check(wanted.length === 0 || inSync, "Plugins synced", "run `lecodes app sync`");
4567
+ const pbxFile = join11(root, "ios", "App.xcodeproj", "project.pbxproj");
4568
+ if (existsSync9(pbxFile) && readFileSync10(pbxFile, "utf8").includes("CODE_SIGN_ENTITLEMENTS")) {
4569
+ check(existsSync9(join11(root, "ios", "App", "App.entitlements")), "App/App.entitlements present", "run `lecodes app sync`");
4570
+ }
4571
+ if (wanted.includes("push")) {
4572
+ const runtimeSwift = join11(root, "ios", "LeCodesRuntime", "Sources", "LeCodesRuntime", "LeCodesRuntime.swift");
4573
+ const baked = existsSync9(runtimeSwift) && readFileSync10(runtimeSwift, "utf8").includes("bootProjectUuid");
4574
+ check(baked, "Push world identity baked", `without it Push.register() rejects "unavailable" — needs a pushed project (uuid), lecodes-ios-sdk ${SDK_IDENTITY_VERSION}+ and \`lecodes app sync\``, false);
4575
+ }
4429
4576
  if (config.update) {
4430
4577
  const plist = readFileSync10(join11(root, "ios", "App", "Info.plist"), "utf8");
4431
4578
  check(plist.includes("<key>LeCodesUpdateURL</key>"), "OTA update URL in Info.plist", "run `lecodes app sync`", false);
@@ -7546,9 +7693,264 @@ var render = async (args) => {
7546
7693
  return done((cb) => process.stdout.write(text, cb));
7547
7694
  };
7548
7695
 
7696
+ // src/commands/pn.ts
7697
+ import { appendFileSync as appendFileSync2, existsSync as existsSync18, readFileSync as readFileSync19, writeFileSync as writeFileSync15 } from "node:fs";
7698
+ import { join as join19 } from "node:path";
7699
+ var ENV_VAR = "LECODES_PUSH_KEY";
7700
+ var context = () => {
7701
+ const root = findProjectRoot(process.cwd());
7702
+ const manifest = readManifest(root);
7703
+ const config = loadConfig();
7704
+ return { root, uuid: manifest.uuid, name: manifest.name, apiUrl: requireApiUrl(config), token: requireToken(config) };
7705
+ };
7706
+ var ago = (iso) => {
7707
+ if (!iso)
7708
+ return c.dim("never");
7709
+ const ms = Date.now() - new Date(iso).getTime();
7710
+ if (!Number.isFinite(ms))
7711
+ return c.dim("—");
7712
+ const mins = Math.round(ms / 60000);
7713
+ if (mins < 1)
7714
+ return "just now";
7715
+ if (mins < 60)
7716
+ return `${mins}m ago`;
7717
+ const hours = Math.round(mins / 60);
7718
+ if (hours < 24)
7719
+ return `${hours}h ago`;
7720
+ return `${Math.round(hours / 24)}d ago`;
7721
+ };
7722
+ var ANSI = /\x1b\[[0-9;]*m/g;
7723
+ var printed = (s) => s.replace(ANSI, "").length;
7724
+ var table = (rows) => {
7725
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => printed(r[i] ?? ""))));
7726
+ for (const row of rows) {
7727
+ const cells = row.map((cell, i) => i === row.length - 1 ? cell : cell + " ".repeat(Math.max(0, widths[i] - printed(cell))));
7728
+ log(" " + cells.join(" "));
7729
+ }
7730
+ };
7731
+ var listFlag = (args, ...keys) => {
7732
+ const raw = flagStr(args, ...keys);
7733
+ if (!raw)
7734
+ return [];
7735
+ return [...new Set(raw.split(",").map((s) => s.trim()).filter(Boolean))];
7736
+ };
7737
+ var pnSend = async (args) => {
7738
+ const ctx = context();
7739
+ const title = flagStr(args, "title", "t") ?? args._[1];
7740
+ if (!title)
7741
+ throw new CliError('A title is required: lecodes pn send --title "Order shipped" --user u42');
7742
+ const users = listFlag(args, "user", "users", "u");
7743
+ const to = listFlag(args, "to", "address", "addresses");
7744
+ if (users.length === 0 && to.length === 0) {
7745
+ throw new CliError("Nothing to send to — pass --user <id> (from Push.register({ user })) or --to <pa_…>. `lecodes pn devices` lists both.");
7746
+ }
7747
+ const badgeRaw = flagStr(args, "badge");
7748
+ const badge = badgeRaw === undefined ? undefined : Number(badgeRaw);
7749
+ if (badge !== undefined && !Number.isInteger(badge))
7750
+ throw new CliError(`--badge must be a whole number (got "${badgeRaw}")`);
7751
+ const dataRaw = flagStr(args, "data");
7752
+ let data;
7753
+ if (dataRaw !== undefined) {
7754
+ try {
7755
+ data = JSON.parse(dataRaw);
7756
+ } catch {
7757
+ throw new CliError(`--data must be a JSON object, e.g. --data '{"orderId":42}'`);
7758
+ }
7759
+ if (!data || typeof data !== "object" || Array.isArray(data))
7760
+ throw new CliError(`--data must be a JSON object, e.g. --data '{"orderId":42}'`);
7761
+ }
7762
+ const payload = {
7763
+ title,
7764
+ body: flagStr(args, "body", "b"),
7765
+ url: flagStr(args, "url"),
7766
+ users: users.length ? users : undefined,
7767
+ to: to.length ? to : undefined,
7768
+ data,
7769
+ badge
7770
+ };
7771
+ const result = await sendPush(ctx.apiUrl, ctx.token, ctx.uuid, payload);
7772
+ if (result.sent > 0)
7773
+ success(`Sent to ${result.sent} device${result.sent === 1 ? "" : "s"}.`);
7774
+ if (result.failed.length === 0) {
7775
+ if (result.sent === 0) {
7776
+ warn("Matched 0 devices — nobody with that user id has registered. Run `lecodes pn devices` to see who has.");
7777
+ }
7778
+ return;
7779
+ }
7780
+ warn(`${result.failed.length} failed:`);
7781
+ for (const failure of result.failed)
7782
+ log(` ${c.dim(failure.address)} ${c.red(failure.reason)}`);
7783
+ };
7784
+ var deviceLabel = (device) => {
7785
+ const env = device.environment === "sandbox" ? c.yellow(" sandbox") : "";
7786
+ return `${device.platform}${env}`;
7787
+ };
7788
+ var pnDevices = async (args) => {
7789
+ const ctx = context();
7790
+ const user = flagStr(args, "user", "u");
7791
+ const forget = flagStr(args, "forget");
7792
+ if (forget) {
7793
+ if (!flagBool(args, "yes", "y")) {
7794
+ warn(`Forgetting ${c.bold(forget)} — sends to this address stop immediately. The device gets a NEW address on its next Push.register().`);
7795
+ if (!await confirm("Forget it?"))
7796
+ return info("Cancelled.");
7797
+ }
7798
+ await forgetPushDevice(ctx.apiUrl, ctx.token, ctx.uuid, forget);
7799
+ success(`Forgot ${c.bold(forget)}.`);
7800
+ return;
7801
+ }
7802
+ const { devices, truncated } = await listPushDevices(ctx.apiUrl, ctx.token, ctx.uuid, user);
7803
+ if (devices.length === 0) {
7804
+ info(user ? `No devices registered for user ${c.bold(user)} in ${c.bold(ctx.name)}.` : `No devices registered in ${c.bold(ctx.name)} yet.`);
7805
+ info(c.dim("A device appears here after the app calls Push.register(). Note that `lecodes dev`"));
7806
+ info(c.dim("bundles are unattributed — register() rejects there; open a PUBLISHED project via QR."));
7807
+ return;
7808
+ }
7809
+ info(`${devices.length} device${devices.length === 1 ? "" : "s"} in ${c.bold(ctx.name)}${user ? ` for user ${c.bold(user)}` : ""}:`);
7810
+ table([
7811
+ [c.dim("ADDRESS"), c.dim("PLATFORM"), c.dim("USER"), c.dim("HOST"), c.dim("LAST SEEN")],
7812
+ ...devices.map((device) => [
7813
+ device.disabledAt ? c.dim(device.address) : device.address,
7814
+ deviceLabel(device),
7815
+ device.user ?? c.dim("—"),
7816
+ device.hostApp,
7817
+ device.disabledAt ? c.red("disabled") : ago(device.lastSeenAt)
7818
+ ])
7819
+ ]);
7820
+ if (devices.some((d) => d.disabledAt)) {
7821
+ info(c.dim("Disabled = the provider reported a dead token; re-registering from the device revives it."));
7822
+ }
7823
+ if (truncated)
7824
+ warn("Showing the 200 most recently seen — there are more.");
7825
+ };
7826
+ var pnKeysList = async (ctx) => {
7827
+ const keys = await listPushKeys(ctx.apiUrl, ctx.token, ctx.uuid);
7828
+ if (keys.length === 0) {
7829
+ info(`No push keys in ${c.bold(ctx.name)}.`);
7830
+ info(c.dim("You only need one to send from your OWN server — `lecodes pn send` uses your login."));
7831
+ info(c.dim('Mint one with `lecodes pn keys new "prod server" --env`.'));
7832
+ return;
7833
+ }
7834
+ info(`${keys.length} push key${keys.length === 1 ? "" : "s"} in ${c.bold(ctx.name)}:`);
7835
+ table([
7836
+ [c.dim("ID"), c.dim("NAME"), c.dim("KEY"), c.dim("LAST USED")],
7837
+ ...keys.map((key) => [String(key.id), key.name, `${key.prefix}${c.dim("…")}`, ago(key.lastUsedAt)])
7838
+ ]);
7839
+ };
7840
+ var writeEnv = (root, key) => {
7841
+ const touched = [];
7842
+ const envPath = join19(root, ".env");
7843
+ const line = `${ENV_VAR}=${key}`;
7844
+ const existing = existsSync18(envPath) ? readFileSync19(envPath, "utf8") : "";
7845
+ if (new RegExp(`^${ENV_VAR}=`, "m").test(existing)) {
7846
+ writeFileSync15(envPath, existing.replace(new RegExp(`^${ENV_VAR}=.*$`, "m"), line));
7847
+ } else {
7848
+ const separator = !existing || existing.endsWith(`
7849
+ `) ? "" : `
7850
+ `;
7851
+ appendFileSync2(envPath, `${separator}${line}
7852
+ `);
7853
+ }
7854
+ touched.push(".env");
7855
+ const ignorePath = join19(root, ".gitignore");
7856
+ const ignore = existsSync18(ignorePath) ? readFileSync19(ignorePath, "utf8") : "";
7857
+ if (!ignore.split(`
7858
+ `).some((l) => l.trim() === ".env")) {
7859
+ const separator = !ignore || ignore.endsWith(`
7860
+ `) ? "" : `
7861
+ `;
7862
+ appendFileSync2(ignorePath, `${separator}.env
7863
+ `);
7864
+ touched.push(".gitignore");
7865
+ }
7866
+ return touched;
7867
+ };
7868
+ var pnKeysNew = async (ctx, args) => {
7869
+ const name = args._[2] ?? flagStr(args, "name");
7870
+ const created = await createPushKey(ctx.apiUrl, ctx.token, ctx.uuid, name);
7871
+ success(`Created push key ${c.bold(created.name)} (id ${created.id}).`);
7872
+ log();
7873
+ log(` ${c.bold(created.key)}`);
7874
+ log();
7875
+ if (flagBool(args, "env")) {
7876
+ const touched = writeEnv(ctx.root, created.key);
7877
+ success(`Wrote ${ENV_VAR} to ${touched.join(" + ")}.`);
7878
+ } else {
7879
+ warn("Copy it now — it is never shown again. Lose it and you mint a new one.");
7880
+ info(c.dim(` Tip: \`lecodes pn keys new --env\` writes it straight into .env as ${ENV_VAR}.`));
7881
+ }
7882
+ log();
7883
+ info(c.dim("Send from your server with:"));
7884
+ log(c.dim(` curl -X POST ${ctx.apiUrl}/api/projects/${ctx.uuid}/push/send \\`));
7885
+ log(c.dim(` -H "Authorization: Bearer $${ENV_VAR}" -H "Content-Type: application/json" \\`));
7886
+ log(c.dim(` -d '{"title":"Order shipped","users":["u42"]}'`));
7887
+ };
7888
+ var pnKeysRevoke = async (ctx, args) => {
7889
+ const raw = args._[2];
7890
+ if (!raw)
7891
+ throw new CliError("Which key? `lecodes pn keys` lists their ids, then `lecodes pn keys revoke <id>`.");
7892
+ const id = Number(raw);
7893
+ if (!Number.isInteger(id))
7894
+ throw new CliError(`"${raw}" is not a key id — run \`lecodes pn keys\` to see them.`);
7895
+ const keys = await listPushKeys(ctx.apiUrl, ctx.token, ctx.uuid);
7896
+ const key = keys.find((k) => k.id === id);
7897
+ if (!key)
7898
+ throw new CliError(`No push key with id ${id} in ${ctx.name}.`);
7899
+ if (!flagBool(args, "yes", "y")) {
7900
+ warn(`Revoking ${c.bold(key.name)} (${key.prefix}…) — any server still using it stops sending immediately.`);
7901
+ if (!await confirm("Revoke it?"))
7902
+ return info("Cancelled.");
7903
+ }
7904
+ await revokePushKey(ctx.apiUrl, ctx.token, ctx.uuid, id);
7905
+ success(`Revoked push key ${c.bold(key.name)}.`);
7906
+ };
7907
+ var pnKeys = async (args) => {
7908
+ const ctx = context();
7909
+ const sub = args._[1];
7910
+ if (!sub || sub === "list")
7911
+ return pnKeysList(ctx);
7912
+ if (sub === "new" || sub === "create")
7913
+ return pnKeysNew(ctx, args);
7914
+ if (sub === "revoke" || sub === "delete" || sub === "rm")
7915
+ return pnKeysRevoke(ctx, args);
7916
+ throw new CliError(`Unknown keys command "${sub}" — use: lecodes pn keys [list | new [name] | revoke <id>]`);
7917
+ };
7918
+ var PN_HELP = `${c.bold("lecodes pn")} — push notifications for this project.
7919
+
7920
+ ${c.bold("Commands:")}
7921
+ pn send --title <s> Send a notification (uses your login — no key needed)
7922
+ --body <s> second line
7923
+ --url <s> deep link (also arrives as app.launchUrl)
7924
+ --user <ids> target Push.register({ user }) ids, comma-separated
7925
+ --to <pa_…> target addresses, comma-separated
7926
+ --badge <n> app icon badge
7927
+ --data <json> your JSON payload (≤ 2 KB)
7928
+ pn devices List the devices that registered for this project
7929
+ --user <id> only that user's devices
7930
+ --forget <pa_…> drop a dead registration (-y to skip confirm)
7931
+ pn keys List this project's push keys
7932
+ pn keys new [name] Mint one for YOUR server (owner only; shown once)
7933
+ --env write it to .env as ${ENV_VAR} (+ gitignore)
7934
+ pn keys revoke <id> Revoke a key (-y to skip the confirmation)
7935
+
7936
+ ${c.dim("A push key is generated BY the server — you can't choose or recover one. It only sends")}
7937
+ ${c.dim("for this project, which is why deploying it beats deploying a personal access token.")}`;
7938
+ var pn = async (args) => {
7939
+ const sub = args._[0];
7940
+ if (!sub || sub === "help")
7941
+ return log(PN_HELP);
7942
+ if (sub === "send")
7943
+ return pnSend(args);
7944
+ if (sub === "devices" || sub === "device")
7945
+ return pnDevices(args);
7946
+ if (sub === "keys" || sub === "key")
7947
+ return pnKeys(args);
7948
+ throw new CliError(`Unknown pn command "${sub}" — use: lecodes pn <send | devices | keys>. Run \`lecodes pn\` for the full list.`);
7949
+ };
7950
+
7549
7951
  // src/commands/pull.ts
7550
- import { existsSync as existsSync18, mkdirSync as mkdirSync14, rmSync as rmSync3, writeFileSync as writeFileSync15 } from "node:fs";
7551
- import { dirname as dirname12, join as join19 } from "node:path";
7952
+ import { existsSync as existsSync19, mkdirSync as mkdirSync14, rmSync as rmSync3, writeFileSync as writeFileSync16 } from "node:fs";
7953
+ import { dirname as dirname12, join as join20 } from "node:path";
7552
7954
  var pull = async (args) => {
7553
7955
  const root = findProjectRoot(process.cwd());
7554
7956
  const manifest = readManifest(root);
@@ -7568,8 +7970,8 @@ var pull = async (args) => {
7568
7970
  const remotePaths = new Set([...buildPaths(project.assets).entries()].filter(([id]) => project.assets.find((a) => a.id === id)?.type !== "folder").map(([, path]) => path));
7569
7971
  for (const path of Object.keys(manifest.files)) {
7570
7972
  if (!remotePaths.has(path)) {
7571
- const abs = join19(root, ...path.replace(/^\//, "").split("/"));
7572
- if (existsSync18(abs))
7973
+ const abs = join20(root, ...path.replace(/^\//, "").split("/"));
7974
+ if (existsSync19(abs))
7573
7975
  rmSync3(abs);
7574
7976
  }
7575
7977
  }
@@ -7607,18 +8009,18 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
7607
8009
  const files = { ...manifest.files };
7608
8010
  for (const asset of targets) {
7609
8011
  const key = pathById.get(asset.id);
7610
- const abs = join19(root, ...key.replace(/^\//, "").split("/"));
8012
+ const abs = join20(root, ...key.replace(/^\//, "").split("/"));
7611
8013
  mkdirSync14(dirname12(abs), { recursive: true });
7612
8014
  let sha;
7613
8015
  if (asset.type === "file") {
7614
8016
  if (!asset.file?.src)
7615
8017
  continue;
7616
8018
  const bytes = await downloadBlob(apiUrl, asset.file.src);
7617
- writeFileSync15(abs, bytes);
8019
+ writeFileSync16(abs, bytes);
7618
8020
  sha = sha256(bytes);
7619
8021
  } else {
7620
8022
  const text = asset.text ?? "";
7621
- writeFileSync15(abs, text);
8023
+ writeFileSync16(abs, text);
7622
8024
  sha = sha256(text);
7623
8025
  }
7624
8026
  files[key] = { id: asset.id, type: kindOf3(asset.type), sha };
@@ -7630,22 +8032,22 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
7630
8032
  };
7631
8033
 
7632
8034
  // src/commands/scene.ts
7633
- import { existsSync as existsSync20, readFileSync as readFileSync20, readdirSync as readdirSync9, statSync as statSync5, writeFileSync as writeFileSync16 } from "node:fs";
7634
- import { basename as basename12, dirname as dirname13, join as join21, resolve as resolve8 } from "node:path";
8035
+ import { existsSync as existsSync21, readFileSync as readFileSync21, readdirSync as readdirSync9, statSync as statSync5, writeFileSync as writeFileSync17 } from "node:fs";
8036
+ import { basename as basename12, dirname as dirname13, join as join22, resolve as resolve8 } from "node:path";
7635
8037
 
7636
8038
  // src/compile/sceneCompile.ts
7637
- import { existsSync as existsSync19, readFileSync as readFileSync19 } from "node:fs";
7638
- import { join as join20 } from "node:path";
8039
+ import { existsSync as existsSync20, readFileSync as readFileSync20 } from "node:fs";
8040
+ import { join as join21 } from "node:path";
7639
8041
  var TEXT_EXT = /\.(ts|js|json|svg|txt|md)$/i;
7640
8042
  var rel = (key) => key.replace(/^\//, "");
7641
8043
  var resolveHarness = (loadSceneHarness2) => {
7642
- const inRepo = join20(distRoot, "..", "projects", "src", "scene-editor");
8044
+ const inRepo = join21(distRoot, "..", "projects", "src", "scene-editor");
7643
8045
  try {
7644
- return loadSceneHarness2(existsSync19(inRepo) ? inRepo : undefined);
8046
+ return loadSceneHarness2(existsSync20(inRepo) ? inRepo : undefined);
7645
8047
  } catch {
7646
- const vendored = join20(distRoot, "runtime", "scene-harness.json");
7647
- if (existsSync19(vendored))
7648
- return JSON.parse(readFileSync19(vendored, "utf8"));
8048
+ const vendored = join21(distRoot, "runtime", "scene-harness.json");
8049
+ if (existsSync20(vendored))
8050
+ return JSON.parse(readFileSync20(vendored, "utf8"));
7649
8051
  throw new CliError("scene-editor harness sources not found — reinstall the CLI or set LECODES_SCENE_HARNESS_DIR.");
7650
8052
  }
7651
8053
  };
@@ -7657,7 +8059,7 @@ var compileSceneEditor = async (opts) => {
7657
8059
  for (const file of scan.files) {
7658
8060
  const path = "./" + rel(file.path);
7659
8061
  if (TEXT_EXT.test(path)) {
7660
- entries.push({ path, type: "text", text: readFileSync19(file.absPath, "utf8") });
8062
+ entries.push({ path, type: "text", text: readFileSync20(file.absPath, "utf8") });
7661
8063
  } else {
7662
8064
  entries.push({ path, type: "resource", fileSrc: "/project/" + rel(file.path) });
7663
8065
  }
@@ -7698,9 +8100,9 @@ var getContext2 = (args) => {
7698
8100
  const pathArg = args._[0];
7699
8101
  if (pathArg) {
7700
8102
  const abs = resolve8(process.cwd(), pathArg);
7701
- if (existsSync20(abs) && statSync5(abs).isDirectory())
8103
+ if (existsSync21(abs) && statSync5(abs).isDirectory())
7702
8104
  root = abs;
7703
- else if (abs.endsWith(".scene.ts") && existsSync20(abs))
8105
+ else if (abs.endsWith(".scene.ts") && existsSync21(abs))
7704
8106
  root = dirname13(abs);
7705
8107
  else
7706
8108
  throw new CliError(`No such folder or .scene.ts: ${pathArg}`);
@@ -7709,12 +8111,12 @@ var getContext2 = (args) => {
7709
8111
  return { root, manifest, name: manifest?.name ?? basename12(root) };
7710
8112
  };
7711
8113
  var writeMcpConfig2 = (root, port) => {
7712
- const path = join21(root, ".mcp.json");
8114
+ const path = join22(root, ".mcp.json");
7713
8115
  const entry = { type: "http", url: `http://localhost:${port}/mcp` };
7714
8116
  let config = {};
7715
- if (existsSync20(path)) {
8117
+ if (existsSync21(path)) {
7716
8118
  try {
7717
- config = JSON.parse(readFileSync20(path, "utf8"));
8119
+ config = JSON.parse(readFileSync21(path, "utf8"));
7718
8120
  } catch {
7719
8121
  warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-scene server by hand.");
7720
8122
  return;
@@ -7727,7 +8129,7 @@ var writeMcpConfig2 = (root, port) => {
7727
8129
  return;
7728
8130
  }
7729
8131
  config.mcpServers["lecodes-scene"] = entry;
7730
- writeFileSync16(path, JSON.stringify(config, null, 2) + `
8132
+ writeFileSync17(path, JSON.stringify(config, null, 2) + `
7731
8133
  `);
7732
8134
  note(`${existing ? "updated" : "created"} .mcp.json (lecodes-scene MCP → ${entry.url})`);
7733
8135
  };
@@ -7736,7 +8138,7 @@ var countScenes = (dir) => {
7736
8138
  for (const name of readdirSync9(dir)) {
7737
8139
  if (name === "node_modules" || name.startsWith("."))
7738
8140
  continue;
7739
- const full = join21(dir, name);
8141
+ const full = join22(dir, name);
7740
8142
  if (statSync5(full).isDirectory())
7741
8143
  n += countScenes(full);
7742
8144
  else if (name.endsWith(".scene.ts"))
@@ -7807,7 +8209,7 @@ var status = async (_args) => {
7807
8209
  };
7808
8210
 
7809
8211
  // src/commands/test.ts
7810
- import { existsSync as existsSync21, readFileSync as readFileSync21, readdirSync as readdirSync10 } from "node:fs";
8212
+ import { existsSync as existsSync22, readFileSync as readFileSync22, readdirSync as readdirSync10 } from "node:fs";
7811
8213
  import { dirname as dirname14, resolve as resolve9 } from "node:path";
7812
8214
  var deriveDesignFlows = (meta, entryScreen) => {
7813
8215
  const edges = meta.edges ?? [];
@@ -7869,7 +8271,7 @@ var slug3 = (s) => s.replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").sl
7869
8271
  var readFlowFile = (abs, displayName) => {
7870
8272
  let raw;
7871
8273
  try {
7872
- raw = JSON.parse(readFileSync21(abs, "utf8"));
8274
+ raw = JSON.parse(readFileSync22(abs, "utf8"));
7873
8275
  } catch (e) {
7874
8276
  throw new CliError(`Can't read "${displayName}": ${e instanceof Error ? e.message : String(e)}`);
7875
8277
  }
@@ -7902,29 +8304,29 @@ var test = async (args) => {
7902
8304
  if (args._.length > 0) {
7903
8305
  for (const given of args._) {
7904
8306
  const candidates = [resolve9(given), resolve9(testsDir, given), resolve9(testsDir, `${given}.flow.json`)];
7905
- const abs = candidates.find((p) => existsSync21(p));
8307
+ const abs = candidates.find((p) => existsSync22(p));
7906
8308
  if (!abs)
7907
8309
  throw new CliError(`No such test: "${given}" (looked in ${testsDir}).`);
7908
8310
  cases.push(readFlowFile(abs, given));
7909
8311
  }
7910
- } else if (existsSync21(testsDir)) {
8312
+ } else if (existsSync22(testsDir)) {
7911
8313
  for (const f2 of readdirSync10(testsDir).filter((f3) => f3.endsWith(".flow.json")).sort()) {
7912
8314
  cases.push(readFlowFile(resolve9(testsDir, f2), f2));
7913
8315
  }
7914
8316
  }
7915
8317
  const fixturesFlag = flagStr(args, "fixtures");
7916
8318
  const defaultPack = resolve9(testsDir, "fixtures.json");
7917
- const flowFixtures = fixturesFlag !== undefined ? resolveFixtures(fixturesFlag, ".") : existsSync21(defaultPack) ? resolveFixtures("fixtures.json", testsDir) : undefined;
8319
+ const flowFixtures = fixturesFlag !== undefined ? resolveFixtures(fixturesFlag, ".") : existsSync22(defaultPack) ? resolveFixtures("fixtures.json", testsDir) : undefined;
7918
8320
  const flowsOnly = flagBool(args, "flows");
7919
8321
  const wantFlows = !flagBool(args, "no-flows") && args._.length === 0;
7920
8322
  if (flowsOnly)
7921
8323
  cases.length = 0;
7922
8324
  let skipped = [];
7923
8325
  let entryScreen = null;
7924
- if ((wantFlows || flowsOnly) && existsSync21(designMetaPath)) {
8326
+ if ((wantFlows || flowsOnly) && existsSync22(designMetaPath)) {
7925
8327
  let meta;
7926
8328
  try {
7927
- meta = JSON.parse(readFileSync21(designMetaPath, "utf8"));
8329
+ meta = JSON.parse(readFileSync22(designMetaPath, "utf8"));
7928
8330
  } catch (e) {
7929
8331
  throw new CliError(`Can't read ${designMetaPath}: ${e instanceof Error ? e.message : String(e)}`);
7930
8332
  }
@@ -8101,15 +8503,15 @@ var types = async (args) => {
8101
8503
  };
8102
8504
 
8103
8505
  // src/index.ts
8104
- import { existsSync as existsSync23, readFileSync as readFileSync23, realpathSync as realpathSync2 } from "node:fs";
8506
+ import { existsSync as existsSync24, readFileSync as readFileSync24, realpathSync as realpathSync2 } from "node:fs";
8105
8507
  import { createRequire as createRequire4 } from "node:module";
8106
- import { delimiter as delimiter2, dirname as dirname16, join as join23 } from "node:path";
8508
+ import { delimiter as delimiter2, dirname as dirname16, join as join24 } from "node:path";
8107
8509
 
8108
8510
  // src/commands/install.ts
8109
8511
  import { execFileSync as execFileSync2 } from "node:child_process";
8110
- import { appendFileSync as appendFileSync2, chmodSync, cpSync as cpSync2, existsSync as existsSync22, mkdirSync as mkdirSync15, readFileSync as readFileSync22, realpathSync, rmSync as rmSync4, symlinkSync } from "node:fs";
8512
+ import { appendFileSync as appendFileSync3, chmodSync, cpSync as cpSync2, existsSync as existsSync23, mkdirSync as mkdirSync15, readFileSync as readFileSync23, realpathSync, rmSync as rmSync4, symlinkSync } from "node:fs";
8111
8513
  import { homedir as homedir3 } from "node:os";
8112
- import { basename as basename13, delimiter, dirname as dirname15, join as join22, resolve as resolve11, sep as sep2 } from "node:path";
8514
+ import { basename as basename13, delimiter, dirname as dirname15, join as join23, resolve as resolve11, sep as sep2 } from "node:path";
8113
8515
  var isWin = process.platform === "win32";
8114
8516
  var HOME = homedir3();
8115
8517
  var safeRealpath = (p) => {
@@ -8135,10 +8537,10 @@ var install = async (args) => {
8135
8537
  const bundle = dirname15(process.execPath);
8136
8538
  const exeName = basename13(process.execPath);
8137
8539
  const chiselName = isWin ? "chisel.exe" : "chisel";
8138
- if (!existsSync22(join22(bundle, "runtime")) || !existsSync22(join22(bundle, chiselName))) {
8540
+ if (!existsSync23(join23(bundle, "runtime")) || !existsSync23(join23(bundle, chiselName))) {
8139
8541
  throw new CliError("`lecodes install` only works from the packaged release build (the archive containing lecodes + " + "chisel + runtime/). You appear to be running from source or an npm install — install globally " + "with your package manager there instead.");
8140
8542
  }
8141
- const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync22(join22(bundle, f2)));
8543
+ const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync23(join23(bundle, f2)));
8142
8544
  if (isWin)
8143
8545
  return installWindows(args, bundle, companions);
8144
8546
  return installUnix(args, bundle, companions);
@@ -8148,9 +8550,9 @@ var copyBundle = (bundle, target, companions) => {
8148
8550
  if (safeRealpath(target) === safeRealpath(bundle))
8149
8551
  return;
8150
8552
  for (const f2 of companions) {
8151
- const dest = join22(target, f2);
8553
+ const dest = join23(target, f2);
8152
8554
  rmSync4(dest, { recursive: true, force: true });
8153
- cpSync2(join22(bundle, f2), dest, { recursive: true });
8555
+ cpSync2(join23(bundle, f2), dest, { recursive: true });
8154
8556
  }
8155
8557
  };
8156
8558
  var permError = (e, dir, system) => {
@@ -8162,44 +8564,44 @@ var permError = (e, dir, system) => {
8162
8564
  };
8163
8565
  var installUnix = (args, bundle, companions) => {
8164
8566
  const system = flagBool(args, "system");
8165
- const libDir = resolve11(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join22(HOME, ".local", "lib", "lecodes-cli")));
8166
- const binDir = resolve11(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join22(HOME, ".local", "bin")));
8167
- const link2 = join22(binDir, "lecodes");
8567
+ const libDir = resolve11(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join23(HOME, ".local", "lib", "lecodes-cli")));
8568
+ const binDir = resolve11(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join23(HOME, ".local", "bin")));
8569
+ const link2 = join23(binDir, "lecodes");
8168
8570
  try {
8169
8571
  copyBundle(bundle, libDir, companions);
8170
- chmodSync(join22(libDir, "lecodes"), 493);
8171
- chmodSync(join22(libDir, "chisel"), 493);
8572
+ chmodSync(join23(libDir, "lecodes"), 493);
8573
+ chmodSync(join23(libDir, "chisel"), 493);
8172
8574
  } catch (e) {
8173
8575
  throw permError(e, libDir, system);
8174
8576
  }
8175
8577
  try {
8176
8578
  mkdirSync15(binDir, { recursive: true });
8177
8579
  rmSync4(link2, { force: true });
8178
- symlinkSync(join22(libDir, "lecodes"), link2);
8580
+ symlinkSync(join23(libDir, "lecodes"), link2);
8179
8581
  } catch (e) {
8180
8582
  throw permError(e, binDir, system);
8181
8583
  }
8182
8584
  success(`Installed to ${c.bold(libDir)}`);
8183
- log(` ${c.dim(link2 + " → " + join22(libDir, "lecodes"))}`);
8585
+ log(` ${c.dim(link2 + " → " + join23(libDir, "lecodes"))}`);
8184
8586
  finishPathUnix(args, binDir);
8185
8587
  };
8186
8588
  var shellRc = () => {
8187
8589
  const shell = basename13(process.env.SHELL ?? "");
8188
8590
  if (shell === "zsh")
8189
- return { file: join22(HOME, ".zshrc"), name: "~/.zshrc" };
8591
+ return { file: join23(HOME, ".zshrc"), name: "~/.zshrc" };
8190
8592
  if (shell === "bash")
8191
- return { file: join22(HOME, ".bashrc"), name: "~/.bashrc" };
8192
- return { file: join22(HOME, ".profile"), name: "~/.profile" };
8593
+ return { file: join23(HOME, ".bashrc"), name: "~/.bashrc" };
8594
+ return { file: join23(HOME, ".profile"), name: "~/.profile" };
8193
8595
  };
8194
8596
  var PATH_MARKER = "# Added by `lecodes install`";
8195
8597
  var appendPathToShellRc = (binDir) => {
8196
8598
  const { file, name } = shellRc();
8197
8599
  const line = `export PATH="${binDir}:$PATH"`;
8198
8600
  try {
8199
- const existing = existsSync22(file) ? readFileSync22(file, "utf8") : "";
8601
+ const existing = existsSync23(file) ? readFileSync23(file, "utf8") : "";
8200
8602
  if (existing.includes(line))
8201
8603
  return name;
8202
- appendFileSync2(file, `${existing && !existing.endsWith(`
8604
+ appendFileSync3(file, `${existing && !existing.endsWith(`
8203
8605
  `) ? `
8204
8606
  ` : ""}
8205
8607
  ${PATH_MARKER}
@@ -8234,7 +8636,7 @@ var finishPathUnix = (args, binDir) => {
8234
8636
  log(` • or install system-wide (no PATH edit): ${c.bold("sudo lecodes install --system")}`);
8235
8637
  };
8236
8638
  var installWindows = (args, bundle, companions) => {
8237
- const target = resolve11(flagStr(args, "dir") ?? join22(process.env.LOCALAPPDATA ?? join22(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
8639
+ const target = resolve11(flagStr(args, "dir") ?? join23(process.env.LOCALAPPDATA ?? join23(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
8238
8640
  try {
8239
8641
  copyBundle(bundle, target, companions);
8240
8642
  } catch (e) {
@@ -8273,7 +8675,7 @@ var addToUserPathWindows = (dir) => {
8273
8675
  var STANDALONE = (() => {
8274
8676
  const dir = dirname16(process.execPath);
8275
8677
  const chisel = process.platform === "win32" ? "chisel.exe" : "chisel";
8276
- return existsSync23(join23(dir, "runtime")) && existsSync23(join23(dir, chisel));
8678
+ return existsSync24(join24(dir, "runtime")) && existsSync24(join24(dir, chisel));
8277
8679
  })();
8278
8680
  var INSTALLED = STANDALONE && (() => {
8279
8681
  const exe = process.platform === "win32" ? "lecodes.exe" : "lecodes";
@@ -8288,8 +8690,8 @@ var INSTALLED = STANDALONE && (() => {
8288
8690
  if (!d)
8289
8691
  return false;
8290
8692
  try {
8291
- const cand = join23(d, exe);
8292
- return existsSync23(cand) && realpathSync2(cand) === self;
8693
+ const cand = join24(d, exe);
8694
+ return existsSync24(cand) && realpathSync2(cand) === self;
8293
8695
  } catch {
8294
8696
  return false;
8295
8697
  }
@@ -8459,9 +8861,18 @@ ${c.bold("Commands:")}
8459
8861
  app upload ios Upload the archive to App Store Connect (then bumps buildNumber)
8460
8862
  --key-path <p8> --key-id <id> --issuer-id <id>
8461
8863
  ASC API key; stored in ~/.lecodes/asc.json
8864
+ pn send --title <s> Push notifications for this project ('lecodes pn' for the group).
8865
+ Sends with your login — no push key needed to test.
8866
+ --user <ids> target Push.register({ user }) ids (comma-sep)
8867
+ --to <pa_…> target addresses (comma-separated)
8868
+ --body/--url/--badge/--data payload fields
8869
+ pn devices Which devices registered (the 'why didn't it arrive?' command)
8870
+ --user <id> only that user's devices
8871
+ pn keys [new|revoke] Push keys for YOUR server: list, mint (owner only, shown once
8872
+ — 'new --env' banks it in .env), revoke <id>
8462
8873
 
8463
8874
  Config lives in ~/.lecodes/config.json (override with LECODES_API / LECODES_TOKEN).`;
8464
- var commands = { init: init2, login, create, clone, link, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, test, types, design, scene, dev, app };
8875
+ var commands = { init: init2, login, create, clone, link, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, test, types, design, scene, dev, app, pn };
8465
8876
  if (STANDALONE)
8466
8877
  commands.install = install;
8467
8878
  var readVersion = () => package_default.version ?? "unknown";
@@ -8475,9 +8886,9 @@ var peerVersion = (name, probe) => {
8475
8886
  const require2 = createRequire4(import.meta.url);
8476
8887
  let dir = dirname16(require2.resolve(probe));
8477
8888
  for (let depth = 0;depth < 16; depth++) {
8478
- const manifest = join23(dir, "package.json");
8479
- if (existsSync23(manifest)) {
8480
- const parsed = JSON.parse(readFileSync23(manifest, "utf8"));
8889
+ const manifest = join24(dir, "package.json");
8890
+ if (existsSync24(manifest)) {
8891
+ const parsed = JSON.parse(readFileSync24(manifest, "utf8"));
8481
8892
  if (parsed.name === name)
8482
8893
  return parsed.version ?? "unknown";
8483
8894
  }