polycast-cli 0.1.0 → 0.3.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/dist/commands.js CHANGED
@@ -6,8 +6,32 @@ import { S, c, g, bold, dim, green, red, yellow, accent, header, section, kv, ba
6
6
  import { DEFAULT_API, DEFAULT_EDGE, findConfig, requireConfig, writeConfig, ensureGitignored, stateDir, } from "./config.js";
7
7
  import { admin, edge, project, requireAdminToken } from "./api.js";
8
8
  import { storeToken, loadToken, clearToken } from "./keychain.js";
9
- import { parseCatalog, mergeTranslations, writeCatalog, serializeCatalog, addMissingKeys } from "./xcstrings.js";
10
- import { scanSwiftSources } from "./scan.js";
9
+ import { parseCatalog, mergeTranslations, writeCatalog, serializeCatalog, addMissingKeys, mergeScannerComments, catalogLocales, } from "./xcstrings.js";
10
+ import * as scanner from "./scan.js";
11
+ import { compileDenyKeys, isFileDenied, partitionPush, pruneCandidates, findPbxprojs, findFilesNamed, hasMemberImportVisibility, parseKnownRegions, userFacingInfoPlistKeys, } from "./hygiene.js";
12
+ /** Scan Swift sources, preferring the detailed scanner (feature-detected —
13
+ * strings + walk stats) and applying polycast.json denyFiles. denyFiles
14
+ * must apply per-occurrence BEFORE the scanner's cross-file dedupe (which
15
+ * attributes each key to the first file walked) — a post-hoc filter would
16
+ * drop keys that also legitimately occur in allowed files. */
17
+ function scanProject(dir, config) {
18
+ const denyFiles = config.denyFiles ?? [];
19
+ const fileFilter = denyFiles.length > 0
20
+ ? (rel) => !isFileDenied(rel, denyFiles)
21
+ : undefined;
22
+ if (typeof scanner.scanSwiftSourcesDetailed === "function") {
23
+ return scanner.scanSwiftSourcesDetailed(dir, {
24
+ extraCalls: config.scanCalls ?? [],
25
+ ...(fileFilter !== undefined ? { fileFilter } : {}),
26
+ });
27
+ }
28
+ // Legacy scanner has no fileFilter hook — post-filter is best-effort.
29
+ const result = { strings: scanner.scanSwiftSources(dir, config.scanCalls ?? []) };
30
+ if (fileFilter !== undefined) {
31
+ result.strings = result.strings.filter((s) => fileFilter(s.file));
32
+ }
33
+ return result;
34
+ }
11
35
  const apiOf = (config) => config?.endpoint ?? DEFAULT_API;
12
36
  const edgeOf = (config) => config?.edgeEndpoint ?? DEFAULT_EDGE;
13
37
  /** Resolve the project id for admin commands from polycast.json's key. */
@@ -117,6 +141,41 @@ function detectCatalog(cwd) {
117
141
  }
118
142
  return null;
119
143
  }
144
+ /** Directory holding the most Swift sources — where the catalog belongs. */
145
+ function bestSourceDir(cwd) {
146
+ const counts = new Map();
147
+ const walk = (d, depth) => {
148
+ if (depth > 4)
149
+ return;
150
+ let entries;
151
+ try {
152
+ entries = readdirSync(d);
153
+ }
154
+ catch {
155
+ return;
156
+ }
157
+ for (const name of entries) {
158
+ if (name.startsWith(".") || ["node_modules", "Pods", "build", "DerivedData", ".build"].includes(name) || name.endsWith(".xcodeproj"))
159
+ continue;
160
+ const full = path.join(d, name);
161
+ try {
162
+ if (statSync(full).isDirectory())
163
+ walk(full, depth + 1);
164
+ else if (name.endsWith(".swift"))
165
+ counts.set(d, (counts.get(d) ?? 0) + 1);
166
+ }
167
+ catch { /* skip */ }
168
+ }
169
+ };
170
+ walk(cwd, 0);
171
+ let best = cwd, bestCount = 0;
172
+ for (const [dir, n] of counts)
173
+ if (n > bestCount) {
174
+ best = dir;
175
+ bestCount = n;
176
+ }
177
+ return best;
178
+ }
120
179
  function detectProjectName(cwd) {
121
180
  const xcodeproj = readdirSync(cwd).find((f) => f.endsWith(".xcodeproj"));
122
181
  if (xcodeproj)
@@ -147,9 +206,16 @@ export async function init(flags) {
147
206
  return;
148
207
  }
149
208
  const token = requireAdminToken();
150
- const catalogPath = flags.catalog ?? detectCatalog(cwd);
209
+ let catalogPath = flags.catalog ?? detectCatalog(cwd);
151
210
  if (!catalogPath) {
152
- throw new CliError("could not find a unique .xcstrings catalog pass --catalog <path>", 2);
211
+ // No String Catalog yet (apps that never localized don't have one)
212
+ // create it. The source scanner fills it from Text("…") literals on
213
+ // the first push, so empty is the correct starting state.
214
+ const appDir = bestSourceDir(cwd);
215
+ catalogPath = path.relative(cwd, path.join(appDir, "Localizable.xcstrings"));
216
+ writeFileSync(path.join(appDir, "Localizable.xcstrings"), JSON.stringify({ sourceLanguage: "en", strings: {}, version: "1.0" }, null, 2) + "\n");
217
+ console.log(` ${S.ok} created ${catalogPath} ${c.muted("(none existed — the source scanner fills it on push)")}`);
218
+ console.log(` ${S.warn} ${c.danger("add it to your app target in Xcode")} ${c.muted("(drag into the project navigator) so bundled fallbacks ship in the binary")}`);
153
219
  }
154
220
  parseCatalog(path.resolve(cwd, catalogPath)); // fail fast if unparseable
155
221
  const name = flags.name ?? detectProjectName(cwd);
@@ -260,31 +326,52 @@ export async function langs(args, flags) {
260
326
  export async function push(flags) {
261
327
  const { config, dir } = requireConfig();
262
328
  const catalogFile = path.resolve(dir, config.catalogPath);
329
+ const denyKeys = compileDenyKeys(config.denyKeys ?? []); // fail fast on bad config
330
+ const protectedTerms = new Set(config.protectedTerms ?? []);
263
331
  // Zero-config capture: PText / Polycast.string literals in Swift
264
332
  // sources are merged into the catalog automatically — write code,
265
- // run push, done.
266
- const scanned = scanSwiftSources(dir, config.scanCalls ?? []);
333
+ // run push, done. denyFiles filtering happens inside scanProject.
334
+ const scanned = scanProject(dir, config).strings;
267
335
  {
268
336
  const { raw } = parseCatalog(catalogFile);
269
337
  const added = addMissingKeys(raw, scanned);
270
- if (added.length > 0) {
338
+ const comments = mergeScannerComments(raw, scanned);
339
+ if (added.length > 0 || comments.adopted.length > 0)
271
340
  writeCatalog(catalogFile, raw);
341
+ if (added.length > 0) {
272
342
  console.log(`\n ${S.ok} captured ${bold(String(added.length))} new string(s) from source ${c.muted(`→ ${config.catalogPath}`)}`);
273
343
  for (const key of added.slice(0, 8))
274
344
  console.log(` ${c.positive("+")} ${c.text(key)}`);
275
345
  if (added.length > 8)
276
346
  console.log(` ${c.muted(`… and ${added.length - 8} more`)}`);
277
347
  }
348
+ if (comments.adopted.length > 0) {
349
+ console.log(` ${S.ok} adopted ${comments.adopted.length} scanner comment(s) into the catalog`);
350
+ }
351
+ for (const col of comments.collisions) {
352
+ console.log(` ${S.warn} ${yellow(`comment collision on "${col.key}"`)} ${c.muted(`catalog: "${col.catalog}" ${g.sep} scanned: "${col.scanned}" — catalog wins`)}`);
353
+ }
278
354
  }
279
355
  const { sourceLocale, strings } = parseCatalog(catalogFile);
356
+ const parts = partitionPush(strings, denyKeys, protectedTerms);
280
357
  console.log(`\n ${S.diamond} ${c.text(`${strings.length} strings`)} ${c.muted(`(catalog + source scan) from ${config.catalogPath}`)}`);
358
+ if (parts.noTranslate > 0) {
359
+ console.log(` ${c.muted(`${g.sep} ${parts.noTranslate} excluded (shouldTranslate = false)`)}`);
360
+ }
361
+ if (parts.denied.length > 0) {
362
+ const shown = parts.denied.slice(0, 5).map((k) => JSON.stringify(k)).join(", ");
363
+ console.log(` ${c.muted(`${g.sep} ${parts.denied.length} excluded by denyKeys: ${shown}${parts.denied.length > 5 ? ` … and ${parts.denied.length - 5} more` : ""}`)}`);
364
+ }
365
+ if (parts.protectedHits.length > 0) {
366
+ console.log(` ${S.warn} ${yellow(`protected term(s) kept verbatim, never pushed: ${parts.protectedHits.map((k) => JSON.stringify(k)).join(", ")}`)}`);
367
+ }
281
368
  const stateFile = path.join(stateDir(dir), "last-push.json");
282
369
  const previous = existsSync(stateFile)
283
370
  ? (JSON.parse(readFileSync(stateFile, "utf8")).digests ?? {})
284
371
  : {};
285
- const changedOrNew = strings.filter((s) => previous[s.key] !== s.digest);
372
+ const changedOrNew = parts.send.filter((s) => previous[s.key] !== s.digest);
286
373
  const newCount = changedOrNew.filter((s) => !(s.key in previous)).length;
287
- console.log(` ${green(`+${newCount} new`)} ${S.dot} ${yellow(`~${changedOrNew.length - newCount} changed`)} ${S.dot} ${dim(`${strings.length - changedOrNew.length} unchanged`)}\n`);
374
+ console.log(` ${green(`+${newCount} new`)} ${S.dot} ${yellow(`~${changedOrNew.length - newCount} changed`)} ${S.dot} ${dim(`${parts.send.length - changedOrNew.length} unchanged`)}\n`);
288
375
  if (changedOrNew.length === 0)
289
376
  return;
290
377
  const sp = spin("pushing source strings");
@@ -292,13 +379,19 @@ export async function push(flags) {
292
379
  schemaVersion: 1,
293
380
  projectKey: config.projectKey,
294
381
  sourceLocale,
295
- strings: changedOrNew.map(({ key, comment, pluralShape, digest }) => ({
296
- key, ...(comment !== undefined ? { comment } : {}),
297
- ...(pluralShape ? { pluralShape } : {}), digest,
382
+ strings: changedOrNew.map(({ key, value, comment, pluralShape, structural, digest }) => ({
383
+ key,
384
+ ...(value !== undefined ? { value } : {}),
385
+ ...(comment !== undefined ? { comment } : {}),
386
+ ...(pluralShape ? { pluralShape } : {}),
387
+ ...(structural ? { structural: true } : {}),
388
+ digest,
298
389
  })),
299
390
  });
300
391
  const digests = { ...previous };
301
- for (const s of strings)
392
+ // Record only keys actually eligible for push un-denying/un-protecting
393
+ // a key later must make it show up as new, not "unchanged".
394
+ for (const s of parts.send)
302
395
  digests[s.key] = s.digest;
303
396
  writeFileSync(stateFile, JSON.stringify({ digests }, null, 2));
304
397
  sp.succeed(`pushed — ${accent(String(result.draftJobsEnqueued))} draft job(s) queued across enabled languages`);
@@ -313,10 +406,36 @@ export async function pull(flags) {
313
406
  const wanted = flags.locales?.split(",").map((s) => s.trim()).filter(Boolean);
314
407
  const locales = manifest.locales.map((l) => l.id)
315
408
  .filter((id) => !wanted || wanted.includes(id));
316
- let changed = 0;
409
+ const bundles = new Map();
317
410
  for (const locale of locales) {
318
- const bundle = await edge.bundle(edgeOf(config), config.projectKey, locale);
319
- changed += mergeTranslations(raw, locale, bundle.strings);
411
+ bundles.set(locale, (await edge.bundle(edgeOf(config), config.projectKey, locale)).strings);
412
+ }
413
+ const missing = new Set();
414
+ let changed = 0;
415
+ for (const [locale, strings] of bundles) {
416
+ changed += mergeTranslations(raw, locale, strings, { missing });
417
+ }
418
+ // Server keys with no local entry are never dropped silently: report
419
+ // them, and create them with --add (en value = key; plural skeleton
420
+ // when any bundle serves plural variants).
421
+ let addedFromServer = [];
422
+ if (missing.size > 0 && flags.add && !flags.check) {
423
+ addedFromServer = [...missing].sort();
424
+ addMissingKeys(raw, addedFromServer.map((key) => ({
425
+ key,
426
+ kind: [...bundles.values()].some((b) => b[key]?.plural) ? "plural" : "simple",
427
+ })));
428
+ for (const [locale, strings] of bundles)
429
+ changed += mergeTranslations(raw, locale, strings);
430
+ }
431
+ else if (missing.size > 0) {
432
+ const first = [...missing].sort().slice(0, 5);
433
+ console.log(` ${S.warn} ${yellow(`${missing.size} server key(s) missing from the local catalog — not merged`)}`);
434
+ for (const key of first)
435
+ console.log(` ${c.muted(g.sep)} ${c.text(JSON.stringify(key))}`);
436
+ if (missing.size > 5)
437
+ console.log(` ${c.muted(`… and ${missing.size - 5} more`)}`);
438
+ note("run `polycast pull --add` to add them to the catalog (en value = key)");
320
439
  }
321
440
  const after = serializeCatalog(raw);
322
441
  if (flags.check) {
@@ -332,8 +451,97 @@ export async function pull(flags) {
332
451
  return;
333
452
  }
334
453
  writeCatalog(catalogFile, raw);
454
+ if (addedFromServer.length > 0) {
455
+ console.log(` ${S.ok} added ${bold(String(addedFromServer.length))} server key(s) to the catalog`);
456
+ for (const key of addedFromServer.slice(0, 5))
457
+ console.log(` ${c.positive("+")} ${c.text(key)}`);
458
+ if (addedFromServer.length > 5)
459
+ console.log(` ${c.muted(`… and ${addedFromServer.length - 5} more`)}`);
460
+ }
335
461
  success(`merged ${locales.map(flag).join(" ")} ${locales.join(", ")} — ${changed} value(s) updated`);
336
462
  }
463
+ // ---- prune ----
464
+ /** List (dry-run) or delete (--apply) catalog junk: letterless non-
465
+ * structural artifacts and dangling concatenation fragments ("Save " when
466
+ * "Save 50%" exists). */
467
+ export async function prune(flags) {
468
+ const { config, dir } = requireConfig();
469
+ const catalogFile = path.resolve(dir, config.catalogPath);
470
+ const { raw } = parseCatalog(catalogFile);
471
+ const { artifacts, fragments } = pruneCandidates(Object.keys(raw.strings));
472
+ const all = [...artifacts, ...fragments];
473
+ if (!flags.json) {
474
+ if (all.length === 0) {
475
+ success("catalog is clean — nothing to prune");
476
+ }
477
+ else {
478
+ header("prune", `${all.length} candidate(s) in ${config.catalogPath}`);
479
+ for (const key of artifacts) {
480
+ console.log(` ${c.negative("−")} ${c.text(JSON.stringify(key))} ${c.muted("empty/letterless artifact")}`);
481
+ }
482
+ for (const key of fragments) {
483
+ console.log(` ${c.negative("−")} ${c.text(JSON.stringify(key))} ${c.muted("dangling concatenation fragment (strict prefix of another key)")}`);
484
+ }
485
+ }
486
+ }
487
+ // Delete BEFORE reporting: `applied` in the machine output must reflect
488
+ // a write that actually happened, not one that was about to.
489
+ const applied = flags.apply && all.length > 0;
490
+ if (applied) {
491
+ for (const key of all)
492
+ delete raw.strings[key];
493
+ writeCatalog(catalogFile, raw);
494
+ }
495
+ if (flags.json) {
496
+ console.log(JSON.stringify({ artifacts, fragments, applied }, null, 2));
497
+ return;
498
+ }
499
+ if (all.length === 0)
500
+ return;
501
+ if (!flags.apply) {
502
+ console.log();
503
+ note("dry run — re-run with --apply to delete these from the catalog");
504
+ console.log();
505
+ return;
506
+ }
507
+ success(`pruned ${all.length} key(s) from ${config.catalogPath}`);
508
+ }
509
+ // ---- misses ----
510
+ /** Runtime miss telemetry: keys devices requested that the OTA bundle
511
+ * could not serve (admin endpoint; newer servers only). */
512
+ export async function misses(flags) {
513
+ const { config, token, projectId } = await resolveProject();
514
+ let limit = 100;
515
+ if (flags.limit !== undefined) {
516
+ limit = Number(flags.limit);
517
+ if (!Number.isInteger(limit) || limit < 1)
518
+ throw new CliError("--limit must be a positive integer", 2);
519
+ }
520
+ const { misses: rows } = await admin.misses(apiOf(config), token, projectId, limit);
521
+ if (flags.json) {
522
+ console.log(JSON.stringify(rows, null, 2));
523
+ return;
524
+ }
525
+ if (rows.length === 0) {
526
+ success("no runtime misses reported");
527
+ return;
528
+ }
529
+ const sorted = [...rows].sort((a, b) => b.count - a.count);
530
+ header("runtime misses", `${rows.length} key/locale pair(s) the SDK could not serve OTA`);
531
+ console.log(table(["key", "locale", "count", "fallback", "last seen"], sorted.map((r) => [
532
+ c.text(r.key) + (r.inCatalog ? "" : ` ${c.danger("(uncaptured)")}`),
533
+ c.accent2(r.locale),
534
+ String(r.count),
535
+ c.muted(r.fallback),
536
+ c.muted(relativeTime(r.lastSeen)),
537
+ ])));
538
+ const uncaptured = sorted.filter((r) => !r.inCatalog).length;
539
+ console.log();
540
+ if (uncaptured > 0) {
541
+ note(`${uncaptured} uncaptured key(s) — the scanner never saw them; check scanCalls in polycast.json or add them to the catalog`);
542
+ console.log();
543
+ }
544
+ }
337
545
  // ---- publish ----
338
546
  export async function publish(args, flags) {
339
547
  const { config, token, projectId } = await resolveProject();
@@ -405,6 +613,22 @@ export async function status(flags) {
405
613
  ? `${c.text(`bundle v${latest.bundleVersion}`)} ${c.positive("live")} ${c.muted(`${g.sep} published ${relativeTime(latest.at)}`)}`
406
614
  : c.muted("nothing published yet"));
407
615
  kv("jobs", failed > 0 ? c.negative(`${failed} failed — inspect with status --json`) : c.positive("all healthy"));
616
+ // Runtime misses (newer servers only — a 404 means the endpoint does
617
+ // not exist yet; hide the line so old servers keep working).
618
+ try {
619
+ const { misses: missRows } = await admin.misses(apiOf(config), token, projectId, 500);
620
+ if (missRows.length > 0) {
621
+ const total = missRows.reduce((a, r) => a + r.count, 0);
622
+ const uncaptured = missRows.filter((r) => !r.inCatalog).length;
623
+ kv("runtime misses", c.danger(`${total} across ${missRows.length} key/locale pair(s)`)
624
+ + (uncaptured > 0 ? c.muted(` ${g.sep} ${uncaptured} uncaptured`) : "")
625
+ + c.muted(` ${g.sep} see \`polycast misses\``));
626
+ }
627
+ }
628
+ catch {
629
+ // Telemetry is informational — a transient 5xx/timeout must never
630
+ // take down `status`. Only `polycast misses` hard-fails.
631
+ }
408
632
  section("languages");
409
633
  console.log(table(["", "locale", "coverage", "", "drafts", "review", "bundle"], enabled.map((l) => [
410
634
  flag(l.locale),
@@ -440,6 +664,7 @@ export async function doctor() {
440
664
  await check("polycast.json present and valid", () => {
441
665
  if (!found)
442
666
  throw new Error("run `polycast init`");
667
+ compileDenyKeys(found.config.denyKeys ?? []); // invalid regex = invalid config
443
668
  return found.config.projectKey.slice(0, 12) + "…";
444
669
  });
445
670
  if (!found) {
@@ -498,6 +723,102 @@ export async function doctor() {
498
723
  if (cached.status !== 304)
499
724
  throw new Error(`expected 304, got ${cached.status}`);
500
725
  });
726
+ // ---- warn-only hygiene (informational; never fails doctor) ----
727
+ const warnOnly = async (label, fn) => {
728
+ const sp = spin(label);
729
+ try {
730
+ const r = await fn();
731
+ const warnings = r.warnings ?? [];
732
+ if (warnings.length === 0) {
733
+ sp.succeed(`${label}${r.detail ? dim(` ${r.detail}`) : ""}`);
734
+ return;
735
+ }
736
+ sp.warn(`${label}${r.detail ? dim(` ${r.detail}`) : ""}`);
737
+ for (const w of warnings)
738
+ console.log(` ${S.warn} ${yellow(w)}`);
739
+ }
740
+ catch (err) {
741
+ sp.warn(`${label} ${dim(`— skipped (${err.message})`)}`);
742
+ }
743
+ };
744
+ const rel = (f) => path.relative(dir, f) || ".";
745
+ const pbxprojs = findPbxprojs(dir);
746
+ await warnOnly("source scan", () => {
747
+ const r = scanProject(dir, config);
748
+ if (!r.stats)
749
+ return { detail: `${r.strings.length} string(s)` };
750
+ const skipped = r.stats.dirsSkipped.length > 0
751
+ ? ` (${r.stats.dirsSkipped.slice(0, 4).map(rel).join(", ")}${r.stats.dirsSkipped.length > 4 ? ", …" : ""})`
752
+ : "";
753
+ return { detail: `${r.strings.length} string(s) ${g.sep} ${r.stats.filesScanned} file(s) scanned ${g.sep} ${r.stats.dirsSkipped.length} dir(s) skipped${skipped}` };
754
+ });
755
+ await warnOnly("member import visibility", () => {
756
+ if (pbxprojs.length === 0)
757
+ return { detail: "no .pbxproj — skipped" };
758
+ const bad = pbxprojs.filter((f) => hasMemberImportVisibility(readFileSync(f, "utf8")));
759
+ return {
760
+ warnings: bad.map((f) => `${rel(f)}: SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES breaks the Text shadow — set it to NO`),
761
+ };
762
+ });
763
+ await warnOnly("Text shadow coverage", () => {
764
+ const roots = detectModuleRoots(dir);
765
+ const missing = roots.filter((r) => !existsSync(path.join(r, "Polycast+Shadow.swift")));
766
+ return {
767
+ detail: `${roots.length - missing.length}/${roots.length} module(s) shadowed`,
768
+ warnings: missing.map((r) => `module ${rel(r)} has no Polycast+Shadow.swift — plain Text stays bundled-only there (run \`polycast shadow\`)`),
769
+ };
770
+ });
771
+ await warnOnly("Info.plist localization", () => {
772
+ const sources = [...findFilesNamed(dir, (n) => n === "Info.plist"), ...pbxprojs];
773
+ const keys = new Set();
774
+ for (const f of sources) {
775
+ for (const k of userFacingInfoPlistKeys(readFileSync(f, "utf8")))
776
+ keys.add(k);
777
+ }
778
+ if (keys.size === 0)
779
+ return { detail: "no user-facing Info.plist keys" };
780
+ if (findFilesNamed(dir, (n) => n === "InfoPlist.xcstrings").length > 0) {
781
+ return { detail: `${keys.size} user-facing key(s), InfoPlist.xcstrings present` };
782
+ }
783
+ return {
784
+ warnings: [`user-facing Info.plist key(s) without an InfoPlist.xcstrings — permission prompts stay unlocalized: ${[...keys].sort().join(", ")}`],
785
+ };
786
+ });
787
+ await warnOnly("Xcode knownRegions", () => {
788
+ if (pbxprojs.length === 0)
789
+ return { detail: "no .pbxproj — skipped" };
790
+ const { raw } = parseCatalog(path.resolve(dir, config.catalogPath));
791
+ const locales = catalogLocales(raw).filter((l) => l !== raw.sourceLanguage);
792
+ if (locales.length === 0)
793
+ return { detail: "no translated locales in the catalog yet" };
794
+ const warnings = [];
795
+ for (const f of pbxprojs) {
796
+ const regions = new Set(parseKnownRegions(readFileSync(f, "utf8")));
797
+ if (regions.size === 0)
798
+ continue;
799
+ const missing = locales.filter((l) => !regions.has(l));
800
+ if (missing.length > 0) {
801
+ warnings.push(`${rel(f)}: knownRegions missing ${missing.join(", ")} — add them in Xcode (Project → Info → Localizations)`);
802
+ }
803
+ }
804
+ return { detail: warnings.length === 0 ? `${locales.length} catalog locale(s) covered` : undefined, warnings };
805
+ });
806
+ await warnOnly("pull currency", async () => {
807
+ const manifest = await edge.manifest(edgeOf(config), config.projectKey);
808
+ if (manifest.locales.length === 0)
809
+ return { detail: "no published bundles yet" };
810
+ const { raw } = parseCatalog(path.resolve(dir, config.catalogPath));
811
+ const stale = [];
812
+ for (const l of manifest.locales) {
813
+ const bundle = await edge.bundle(edgeOf(config), config.projectKey, l.id);
814
+ const clone = structuredClone(raw);
815
+ if (mergeTranslations(clone, l.id, bundle.strings) > 0)
816
+ stale.push(l.id);
817
+ }
818
+ if (stale.length === 0)
819
+ return { detail: `catalog current with ${manifest.locales.length} published locale(s)` };
820
+ return { warnings: [`catalog fallbacks stale for ${stale.join(", ")} — run \`polycast pull\``] };
821
+ });
501
822
  if (failures > 0) {
502
823
  console.log(`\n ${S.fail} ${red(`${failures} check(s) failed`)}\n`);
503
824
  process.exit(1);
@@ -512,39 +833,18 @@ const SHADOW_CONTENT = `// Generated by \`polycast shadow\`: routes every plain
512
833
  import PolycastSDK
513
834
  typealias Text = PText
514
835
  `;
515
- /** Plant Polycast+Shadow.swift into each source module so plain Text —
516
- * and any design-system component built on it renders OTA. */
836
+ /** Plant Polycast+Shadow.swift once per Swift MODULE (a typealias is
837
+ * module-scoped; duplicates in the same module are compile errors).
838
+ * SPM packages get one per Sources/<target>; an Xcode app (no package
839
+ * boundary) is a single module and gets exactly one at its source root. */
517
840
  export async function shadow(args, _flags) {
518
841
  const { dir } = requireConfig();
519
- let targets = args.map((a) => path.resolve(dir, a));
520
- if (targets.length === 0) {
521
- // Default: every directory that looks like a Swift source root.
522
- const roots = new Set();
523
- const walk = (d, depth) => {
524
- if (depth > 5)
525
- return;
526
- let entries;
527
- try {
528
- entries = readdirSync(d);
529
- }
530
- catch {
531
- return;
532
- }
533
- for (const name of entries) {
534
- if (name.startsWith(".") || ["node_modules", "Pods", ".build", "build", "DerivedData"].includes(name))
535
- continue;
536
- const full = path.join(d, name);
537
- try {
538
- if (statSync(full).isDirectory())
539
- walk(full, depth + 1);
540
- else if (name.endsWith(".swift") && !name.startsWith("Polycast+") && name !== "Package.swift")
541
- roots.add(d);
542
- }
543
- catch { /* skip */ }
544
- }
545
- };
546
- walk(dir, 0);
547
- targets = [...roots];
842
+ let targets;
843
+ if (args.length > 0) {
844
+ targets = args.map((a) => path.resolve(dir, a));
845
+ }
846
+ else {
847
+ targets = detectModuleRoots(dir);
548
848
  }
549
849
  let planted = 0;
550
850
  for (const target of targets) {
@@ -552,16 +852,79 @@ export async function shadow(args, _flags) {
552
852
  if (existsSync(file))
553
853
  continue;
554
854
  writeFileSync(file, SHADOW_CONTENT);
555
- console.log(` ${S.ok} ${path.relative(dir, file)}`);
855
+ console.log(` ${S.ok} ${path.relative(dir, file) || "Polycast+Shadow.swift"}`);
556
856
  planted++;
557
857
  }
558
858
  if (planted === 0) {
559
- note("all source modules already shadowed");
859
+ note("all modules already shadowed");
560
860
  return;
561
861
  }
562
- success(`planted ${planted} shadow file(s) — plain Text now renders OTA in those modules`);
862
+ success(`planted ${planted} shadow file(s) — plain Text now renders OTA`);
863
+ note("Xcode app targets: make sure the file is part of the target (drag into the navigator if needed)");
563
864
  note("using XcodeGen? re-run `xcodegen generate` so the new files join the project");
564
865
  }
866
+ /** One root per Swift module. */
867
+ function detectModuleRoots(root) {
868
+ const swiftDirs = [];
869
+ const packageDirs = [];
870
+ const walk = (d, depth) => {
871
+ if (depth > 6)
872
+ return;
873
+ let entries;
874
+ try {
875
+ entries = readdirSync(d);
876
+ }
877
+ catch {
878
+ return;
879
+ }
880
+ if (entries.includes("Package.swift") && d !== root)
881
+ packageDirs.push(d);
882
+ for (const name of entries) {
883
+ if (name.startsWith(".") || ["node_modules", "Pods", ".build", "build", "DerivedData"].includes(name) || name.endsWith(".xcodeproj"))
884
+ continue;
885
+ const full = path.join(d, name);
886
+ try {
887
+ if (statSync(full).isDirectory())
888
+ walk(full, depth + 1);
889
+ else if (name.endsWith(".swift") && !name.startsWith("Polycast+") && name !== "Package.swift")
890
+ swiftDirs.push(d);
891
+ }
892
+ catch { /* skip */ }
893
+ }
894
+ };
895
+ walk(root, 0);
896
+ if (existsSync(path.join(root, "Package.swift")))
897
+ packageDirs.push(root);
898
+ const roots = new Set();
899
+ const inPackage = (d) => packageDirs.find((p) => d === p || d.startsWith(p + path.sep));
900
+ // SPM: one module per Sources/<target> directory.
901
+ for (const pkg of packageDirs) {
902
+ const sources = path.join(pkg, "Sources");
903
+ if (existsSync(sources)) {
904
+ for (const t of readdirSync(sources)) {
905
+ const td = path.join(sources, t);
906
+ try {
907
+ if (statSync(td).isDirectory())
908
+ roots.add(td);
909
+ }
910
+ catch { /* skip */ }
911
+ }
912
+ }
913
+ }
914
+ // Xcode app (everything outside package boundaries): ONE module — the
915
+ // common ancestor of its Swift dirs.
916
+ const appDirs = [...new Set(swiftDirs)].filter((d) => !inPackage(d));
917
+ if (appDirs.length > 0) {
918
+ let common = appDirs[0];
919
+ for (const d of appDirs) {
920
+ while (!(d === common || d.startsWith(common + path.sep))) {
921
+ common = path.dirname(common);
922
+ }
923
+ }
924
+ roots.add(common);
925
+ }
926
+ return [...roots];
927
+ }
565
928
  // ---- gen ----
566
929
  function swiftIdent(part) {
567
930
  const cleaned = part.replace(/[^A-Za-z0-9]+(.)?/g, (_, c) => c ? c.toUpperCase() : "");