polycast-cli 0.2.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/api.d.ts +15 -0
- package/dist/api.js +5 -2
- package/dist/api.js.map +1 -1
- package/dist/commands.d.ts +10 -0
- package/dist/commands.js +293 -14
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +9 -0
- package/dist/config.js.map +1 -1
- package/dist/hygiene.d.ts +48 -0
- package/dist/hygiene.js +213 -0
- package/dist/hygiene.js.map +1 -0
- package/dist/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/dist/scan.d.ts +72 -16
- package/dist/scan.js +662 -129
- package/dist/scan.js.map +1 -1
- package/dist/util.d.ts +7 -1
- package/dist/util.js +6 -1
- package/dist/util.js.map +1 -1
- package/dist/xcstrings.d.ts +44 -5
- package/dist/xcstrings.js +63 -10
- package/dist/xcstrings.js.map +1 -1
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -41,7 +41,22 @@ export declare const admin: {
|
|
|
41
41
|
etag: string;
|
|
42
42
|
}>;
|
|
43
43
|
}>;
|
|
44
|
+
/** Runtime miss telemetry (newer servers only — callers should treat a
|
|
45
|
+
* CliError with status 404 as "endpoint not there yet" and hide). */
|
|
46
|
+
misses: (api: string, token: string, projectId: string, limit?: number) => Promise<{
|
|
47
|
+
misses: MissRow[];
|
|
48
|
+
}>;
|
|
44
49
|
};
|
|
50
|
+
export interface MissRow {
|
|
51
|
+
key: string;
|
|
52
|
+
locale: string;
|
|
53
|
+
count: number;
|
|
54
|
+
fallback: "bundled" | "key";
|
|
55
|
+
firstSeen: string;
|
|
56
|
+
lastSeen: string;
|
|
57
|
+
/** false = the scanner never captured this key — it needs adding. */
|
|
58
|
+
inCatalog: boolean;
|
|
59
|
+
}
|
|
45
60
|
export interface ProjectStatus {
|
|
46
61
|
project: {
|
|
47
62
|
id: string;
|
package/dist/api.js
CHANGED
|
@@ -23,9 +23,9 @@ async function request(url, init = {}) {
|
|
|
23
23
|
if (!res.ok) {
|
|
24
24
|
const e = body?.error;
|
|
25
25
|
if (res.status === 401) {
|
|
26
|
-
throw new CliError(e?.message ?? "unauthorized — run `polycast login` (admin) or check polycast.json (project key)");
|
|
26
|
+
throw new CliError(e?.message ?? "unauthorized — run `polycast login` (admin) or check polycast.json (project key)", 1, 401);
|
|
27
27
|
}
|
|
28
|
-
throw new CliError(`${init.method ?? "GET"} ${new URL(url).pathname} → ${res.status}: ${e?.message ?? text.slice(0, 200)}
|
|
28
|
+
throw new CliError(`${init.method ?? "GET"} ${new URL(url).pathname} → ${res.status}: ${e?.message ?? text.slice(0, 200)}`, 1, res.status);
|
|
29
29
|
}
|
|
30
30
|
return body;
|
|
31
31
|
}
|
|
@@ -45,6 +45,9 @@ export const admin = {
|
|
|
45
45
|
status: (api, token, projectId) => request(`${api}/v1/admin/projects/${projectId}/status`, { auth: token }),
|
|
46
46
|
getProject: (api, token, projectId) => request(`${api}/v1/admin/projects/${projectId}`, { auth: token }),
|
|
47
47
|
publish: (api, token, projectId, locales) => request(`${api}/v1/admin/projects/${projectId}/publish`, { method: "POST", auth: token, body: JSON.stringify(locales?.length ? { locales } : {}) }),
|
|
48
|
+
/** Runtime miss telemetry (newer servers only — callers should treat a
|
|
49
|
+
* CliError with status 404 as "endpoint not there yet" and hide). */
|
|
50
|
+
misses: (api, token, projectId, limit) => request(`${api}/v1/admin/projects/${projectId}/misses${limit !== undefined ? `?limit=${limit}` : ""}`, { auth: token }),
|
|
48
51
|
};
|
|
49
52
|
// ---- Project key surfaces ----
|
|
50
53
|
export const project = {
|
package/dist/api.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC1C,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAIrC,KAAK,UAAU,OAAO,CAAI,GAAW,EAAE,OAAwC,EAAE;IAC/E,MAAM,OAAO,GAA2B;QACtC,cAAc,EAAE,kBAAkB;QAClC,GAAI,IAAI,CAAC,OAA8C;KACxD,CAAC;IACF,IAAI,IAAI,CAAC,IAAI;QAAE,OAAO,CAAC,eAAe,CAAC,GAAG,UAAU,IAAI,CAAC,IAAI,EAAE,CAAC;IAChE,IAAI,GAAa,CAAC;IAClB,IAAI,CAAC;QACH,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;IAC/C,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,IAAI,QAAQ,CAAC,gBAAgB,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,MAAM,MAAO,GAAa,CAAC,OAAO,EAAE,CAAC,CAAC;IACxF,CAAC;IACD,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;IAC9B,IAAI,IAAI,GAAY,IAAI,CAAC;IACzB,IAAI,CAAC;QAAC,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC,CAAC,yBAAyB,CAAC,CAAC;IAClF,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,CAAC,GAAI,IAAsB,EAAE,KAAK,CAAC;QACzC,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;YACvB,MAAM,IAAI,QAAQ,CAAC,CAAC,EAAE,OAAO,IAAI,kFAAkF,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
|
package/dist/commands.d.ts
CHANGED
|
@@ -4,12 +4,15 @@ export interface Flags {
|
|
|
4
4
|
wait: boolean;
|
|
5
5
|
check: boolean;
|
|
6
6
|
token: boolean;
|
|
7
|
+
add: boolean;
|
|
8
|
+
apply: boolean;
|
|
7
9
|
name?: string;
|
|
8
10
|
catalog?: string;
|
|
9
11
|
endpoint?: string;
|
|
10
12
|
output?: string;
|
|
11
13
|
locales?: string;
|
|
12
14
|
org?: string;
|
|
15
|
+
limit?: string;
|
|
13
16
|
}
|
|
14
17
|
export declare function login(flags: Flags): Promise<void>;
|
|
15
18
|
export declare function whoami(flags: Flags): Promise<void>;
|
|
@@ -18,6 +21,13 @@ export declare function init(flags: Flags): Promise<void>;
|
|
|
18
21
|
export declare function langs(args: string[], flags: Flags): Promise<void>;
|
|
19
22
|
export declare function push(flags: Flags): Promise<void>;
|
|
20
23
|
export declare function pull(flags: Flags): Promise<void>;
|
|
24
|
+
/** List (dry-run) or delete (--apply) catalog junk: letterless non-
|
|
25
|
+
* structural artifacts and dangling concatenation fragments ("Save " when
|
|
26
|
+
* "Save 50%" exists). */
|
|
27
|
+
export declare function prune(flags: Flags): Promise<void>;
|
|
28
|
+
/** Runtime miss telemetry: keys devices requested that the OTA bundle
|
|
29
|
+
* could not serve (admin endpoint; newer servers only). */
|
|
30
|
+
export declare function misses(flags: Flags): Promise<void>;
|
|
21
31
|
export declare function publish(args: string[], flags: Flags): Promise<void>;
|
|
22
32
|
export declare function status(flags: Flags): Promise<void>;
|
|
23
33
|
export declare function doctor(): Promise<void>;
|
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
|
|
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. */
|
|
@@ -302,31 +326,52 @@ export async function langs(args, flags) {
|
|
|
302
326
|
export async function push(flags) {
|
|
303
327
|
const { config, dir } = requireConfig();
|
|
304
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 ?? []);
|
|
305
331
|
// Zero-config capture: PText / Polycast.string literals in Swift
|
|
306
332
|
// sources are merged into the catalog automatically — write code,
|
|
307
|
-
// run push, done.
|
|
308
|
-
const scanned =
|
|
333
|
+
// run push, done. denyFiles filtering happens inside scanProject.
|
|
334
|
+
const scanned = scanProject(dir, config).strings;
|
|
309
335
|
{
|
|
310
336
|
const { raw } = parseCatalog(catalogFile);
|
|
311
337
|
const added = addMissingKeys(raw, scanned);
|
|
312
|
-
|
|
338
|
+
const comments = mergeScannerComments(raw, scanned);
|
|
339
|
+
if (added.length > 0 || comments.adopted.length > 0)
|
|
313
340
|
writeCatalog(catalogFile, raw);
|
|
341
|
+
if (added.length > 0) {
|
|
314
342
|
console.log(`\n ${S.ok} captured ${bold(String(added.length))} new string(s) from source ${c.muted(`→ ${config.catalogPath}`)}`);
|
|
315
343
|
for (const key of added.slice(0, 8))
|
|
316
344
|
console.log(` ${c.positive("+")} ${c.text(key)}`);
|
|
317
345
|
if (added.length > 8)
|
|
318
346
|
console.log(` ${c.muted(`… and ${added.length - 8} more`)}`);
|
|
319
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
|
+
}
|
|
320
354
|
}
|
|
321
355
|
const { sourceLocale, strings } = parseCatalog(catalogFile);
|
|
356
|
+
const parts = partitionPush(strings, denyKeys, protectedTerms);
|
|
322
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
|
+
}
|
|
323
368
|
const stateFile = path.join(stateDir(dir), "last-push.json");
|
|
324
369
|
const previous = existsSync(stateFile)
|
|
325
370
|
? (JSON.parse(readFileSync(stateFile, "utf8")).digests ?? {})
|
|
326
371
|
: {};
|
|
327
|
-
const changedOrNew =
|
|
372
|
+
const changedOrNew = parts.send.filter((s) => previous[s.key] !== s.digest);
|
|
328
373
|
const newCount = changedOrNew.filter((s) => !(s.key in previous)).length;
|
|
329
|
-
console.log(` ${green(`+${newCount} new`)} ${S.dot} ${yellow(`~${changedOrNew.length - newCount} changed`)} ${S.dot} ${dim(`${
|
|
374
|
+
console.log(` ${green(`+${newCount} new`)} ${S.dot} ${yellow(`~${changedOrNew.length - newCount} changed`)} ${S.dot} ${dim(`${parts.send.length - changedOrNew.length} unchanged`)}\n`);
|
|
330
375
|
if (changedOrNew.length === 0)
|
|
331
376
|
return;
|
|
332
377
|
const sp = spin("pushing source strings");
|
|
@@ -334,13 +379,19 @@ export async function push(flags) {
|
|
|
334
379
|
schemaVersion: 1,
|
|
335
380
|
projectKey: config.projectKey,
|
|
336
381
|
sourceLocale,
|
|
337
|
-
strings: changedOrNew.map(({ key, comment, pluralShape, digest }) => ({
|
|
338
|
-
key,
|
|
339
|
-
...(
|
|
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,
|
|
340
389
|
})),
|
|
341
390
|
});
|
|
342
391
|
const digests = { ...previous };
|
|
343
|
-
for
|
|
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)
|
|
344
395
|
digests[s.key] = s.digest;
|
|
345
396
|
writeFileSync(stateFile, JSON.stringify({ digests }, null, 2));
|
|
346
397
|
sp.succeed(`pushed — ${accent(String(result.draftJobsEnqueued))} draft job(s) queued across enabled languages`);
|
|
@@ -355,10 +406,36 @@ export async function pull(flags) {
|
|
|
355
406
|
const wanted = flags.locales?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
356
407
|
const locales = manifest.locales.map((l) => l.id)
|
|
357
408
|
.filter((id) => !wanted || wanted.includes(id));
|
|
358
|
-
|
|
409
|
+
const bundles = new Map();
|
|
359
410
|
for (const locale of locales) {
|
|
360
|
-
|
|
361
|
-
|
|
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)");
|
|
362
439
|
}
|
|
363
440
|
const after = serializeCatalog(raw);
|
|
364
441
|
if (flags.check) {
|
|
@@ -374,8 +451,97 @@ export async function pull(flags) {
|
|
|
374
451
|
return;
|
|
375
452
|
}
|
|
376
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
|
+
}
|
|
377
461
|
success(`merged ${locales.map(flag).join(" ")} ${locales.join(", ")} — ${changed} value(s) updated`);
|
|
378
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
|
+
}
|
|
379
545
|
// ---- publish ----
|
|
380
546
|
export async function publish(args, flags) {
|
|
381
547
|
const { config, token, projectId } = await resolveProject();
|
|
@@ -447,6 +613,22 @@ export async function status(flags) {
|
|
|
447
613
|
? `${c.text(`bundle v${latest.bundleVersion}`)} ${c.positive("live")} ${c.muted(`${g.sep} published ${relativeTime(latest.at)}`)}`
|
|
448
614
|
: c.muted("nothing published yet"));
|
|
449
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
|
+
}
|
|
450
632
|
section("languages");
|
|
451
633
|
console.log(table(["", "locale", "coverage", "", "drafts", "review", "bundle"], enabled.map((l) => [
|
|
452
634
|
flag(l.locale),
|
|
@@ -482,6 +664,7 @@ export async function doctor() {
|
|
|
482
664
|
await check("polycast.json present and valid", () => {
|
|
483
665
|
if (!found)
|
|
484
666
|
throw new Error("run `polycast init`");
|
|
667
|
+
compileDenyKeys(found.config.denyKeys ?? []); // invalid regex = invalid config
|
|
485
668
|
return found.config.projectKey.slice(0, 12) + "…";
|
|
486
669
|
});
|
|
487
670
|
if (!found) {
|
|
@@ -540,6 +723,102 @@ export async function doctor() {
|
|
|
540
723
|
if (cached.status !== 304)
|
|
541
724
|
throw new Error(`expected 304, got ${cached.status}`);
|
|
542
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
|
+
});
|
|
543
822
|
if (failures > 0) {
|
|
544
823
|
console.log(`\n ${S.fail} ${red(`${failures} check(s) failed`)}\n`);
|
|
545
824
|
process.exit(1);
|