polycast-cli 0.2.0 → 0.3.1
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 +18 -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 +297 -15
- 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;
|
|
@@ -49,6 +64,9 @@ export interface ProjectStatus {
|
|
|
49
64
|
sourceLocale: string;
|
|
50
65
|
};
|
|
51
66
|
totalStrings: number;
|
|
67
|
+
/** totalStrings minus structural joiner keys (which never AI-draft).
|
|
68
|
+
* Absent on pre-0.3 servers — fall back to totalStrings. */
|
|
69
|
+
draftableStrings?: number;
|
|
52
70
|
locales: Array<{
|
|
53
71
|
locale: string;
|
|
54
72
|
nativeName: 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();
|
|
@@ -386,8 +552,11 @@ export async function publish(args, flags) {
|
|
|
386
552
|
const deadline = Date.now() + 10 * 60_000;
|
|
387
553
|
for (;;) {
|
|
388
554
|
const status = await admin.status(api, token, projectId);
|
|
555
|
+
// Structural joiner keys never AI-draft, so the horizon is the
|
|
556
|
+
// server's draftableStrings (fall back for pre-0.3 servers).
|
|
557
|
+
const horizon = status.draftableStrings ?? status.totalStrings;
|
|
389
558
|
const pending = status.locales.filter((l) => l.enabled && (targets.length === 0 || targets.includes(l.locale)) &&
|
|
390
|
-
l.published + l.drafts + l.needsReview <
|
|
559
|
+
l.published + l.drafts + l.needsReview < horizon);
|
|
391
560
|
if (pending.length === 0) {
|
|
392
561
|
sp.succeed("drafting complete");
|
|
393
562
|
break;
|
|
@@ -447,6 +616,22 @@ export async function status(flags) {
|
|
|
447
616
|
? `${c.text(`bundle v${latest.bundleVersion}`)} ${c.positive("live")} ${c.muted(`${g.sep} published ${relativeTime(latest.at)}`)}`
|
|
448
617
|
: c.muted("nothing published yet"));
|
|
449
618
|
kv("jobs", failed > 0 ? c.negative(`${failed} failed — inspect with status --json`) : c.positive("all healthy"));
|
|
619
|
+
// Runtime misses (newer servers only — a 404 means the endpoint does
|
|
620
|
+
// not exist yet; hide the line so old servers keep working).
|
|
621
|
+
try {
|
|
622
|
+
const { misses: missRows } = await admin.misses(apiOf(config), token, projectId, 500);
|
|
623
|
+
if (missRows.length > 0) {
|
|
624
|
+
const total = missRows.reduce((a, r) => a + r.count, 0);
|
|
625
|
+
const uncaptured = missRows.filter((r) => !r.inCatalog).length;
|
|
626
|
+
kv("runtime misses", c.danger(`${total} across ${missRows.length} key/locale pair(s)`)
|
|
627
|
+
+ (uncaptured > 0 ? c.muted(` ${g.sep} ${uncaptured} uncaptured`) : "")
|
|
628
|
+
+ c.muted(` ${g.sep} see \`polycast misses\``));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
// Telemetry is informational — a transient 5xx/timeout must never
|
|
633
|
+
// take down `status`. Only `polycast misses` hard-fails.
|
|
634
|
+
}
|
|
450
635
|
section("languages");
|
|
451
636
|
console.log(table(["", "locale", "coverage", "", "drafts", "review", "bundle"], enabled.map((l) => [
|
|
452
637
|
flag(l.locale),
|
|
@@ -482,6 +667,7 @@ export async function doctor() {
|
|
|
482
667
|
await check("polycast.json present and valid", () => {
|
|
483
668
|
if (!found)
|
|
484
669
|
throw new Error("run `polycast init`");
|
|
670
|
+
compileDenyKeys(found.config.denyKeys ?? []); // invalid regex = invalid config
|
|
485
671
|
return found.config.projectKey.slice(0, 12) + "…";
|
|
486
672
|
});
|
|
487
673
|
if (!found) {
|
|
@@ -540,6 +726,102 @@ export async function doctor() {
|
|
|
540
726
|
if (cached.status !== 304)
|
|
541
727
|
throw new Error(`expected 304, got ${cached.status}`);
|
|
542
728
|
});
|
|
729
|
+
// ---- warn-only hygiene (informational; never fails doctor) ----
|
|
730
|
+
const warnOnly = async (label, fn) => {
|
|
731
|
+
const sp = spin(label);
|
|
732
|
+
try {
|
|
733
|
+
const r = await fn();
|
|
734
|
+
const warnings = r.warnings ?? [];
|
|
735
|
+
if (warnings.length === 0) {
|
|
736
|
+
sp.succeed(`${label}${r.detail ? dim(` ${r.detail}`) : ""}`);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
sp.warn(`${label}${r.detail ? dim(` ${r.detail}`) : ""}`);
|
|
740
|
+
for (const w of warnings)
|
|
741
|
+
console.log(` ${S.warn} ${yellow(w)}`);
|
|
742
|
+
}
|
|
743
|
+
catch (err) {
|
|
744
|
+
sp.warn(`${label} ${dim(`— skipped (${err.message})`)}`);
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
const rel = (f) => path.relative(dir, f) || ".";
|
|
748
|
+
const pbxprojs = findPbxprojs(dir);
|
|
749
|
+
await warnOnly("source scan", () => {
|
|
750
|
+
const r = scanProject(dir, config);
|
|
751
|
+
if (!r.stats)
|
|
752
|
+
return { detail: `${r.strings.length} string(s)` };
|
|
753
|
+
const skipped = r.stats.dirsSkipped.length > 0
|
|
754
|
+
? ` (${r.stats.dirsSkipped.slice(0, 4).map(rel).join(", ")}${r.stats.dirsSkipped.length > 4 ? ", …" : ""})`
|
|
755
|
+
: "";
|
|
756
|
+
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}` };
|
|
757
|
+
});
|
|
758
|
+
await warnOnly("member import visibility", () => {
|
|
759
|
+
if (pbxprojs.length === 0)
|
|
760
|
+
return { detail: "no .pbxproj — skipped" };
|
|
761
|
+
const bad = pbxprojs.filter((f) => hasMemberImportVisibility(readFileSync(f, "utf8")));
|
|
762
|
+
return {
|
|
763
|
+
warnings: bad.map((f) => `${rel(f)}: SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES breaks the Text shadow — set it to NO`),
|
|
764
|
+
};
|
|
765
|
+
});
|
|
766
|
+
await warnOnly("Text shadow coverage", () => {
|
|
767
|
+
const roots = detectModuleRoots(dir);
|
|
768
|
+
const missing = roots.filter((r) => !existsSync(path.join(r, "Polycast+Shadow.swift")));
|
|
769
|
+
return {
|
|
770
|
+
detail: `${roots.length - missing.length}/${roots.length} module(s) shadowed`,
|
|
771
|
+
warnings: missing.map((r) => `module ${rel(r)} has no Polycast+Shadow.swift — plain Text stays bundled-only there (run \`polycast shadow\`)`),
|
|
772
|
+
};
|
|
773
|
+
});
|
|
774
|
+
await warnOnly("Info.plist localization", () => {
|
|
775
|
+
const sources = [...findFilesNamed(dir, (n) => n === "Info.plist"), ...pbxprojs];
|
|
776
|
+
const keys = new Set();
|
|
777
|
+
for (const f of sources) {
|
|
778
|
+
for (const k of userFacingInfoPlistKeys(readFileSync(f, "utf8")))
|
|
779
|
+
keys.add(k);
|
|
780
|
+
}
|
|
781
|
+
if (keys.size === 0)
|
|
782
|
+
return { detail: "no user-facing Info.plist keys" };
|
|
783
|
+
if (findFilesNamed(dir, (n) => n === "InfoPlist.xcstrings").length > 0) {
|
|
784
|
+
return { detail: `${keys.size} user-facing key(s), InfoPlist.xcstrings present` };
|
|
785
|
+
}
|
|
786
|
+
return {
|
|
787
|
+
warnings: [`user-facing Info.plist key(s) without an InfoPlist.xcstrings — permission prompts stay unlocalized: ${[...keys].sort().join(", ")}`],
|
|
788
|
+
};
|
|
789
|
+
});
|
|
790
|
+
await warnOnly("Xcode knownRegions", () => {
|
|
791
|
+
if (pbxprojs.length === 0)
|
|
792
|
+
return { detail: "no .pbxproj — skipped" };
|
|
793
|
+
const { raw } = parseCatalog(path.resolve(dir, config.catalogPath));
|
|
794
|
+
const locales = catalogLocales(raw).filter((l) => l !== raw.sourceLanguage);
|
|
795
|
+
if (locales.length === 0)
|
|
796
|
+
return { detail: "no translated locales in the catalog yet" };
|
|
797
|
+
const warnings = [];
|
|
798
|
+
for (const f of pbxprojs) {
|
|
799
|
+
const regions = new Set(parseKnownRegions(readFileSync(f, "utf8")));
|
|
800
|
+
if (regions.size === 0)
|
|
801
|
+
continue;
|
|
802
|
+
const missing = locales.filter((l) => !regions.has(l));
|
|
803
|
+
if (missing.length > 0) {
|
|
804
|
+
warnings.push(`${rel(f)}: knownRegions missing ${missing.join(", ")} — add them in Xcode (Project → Info → Localizations)`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return { detail: warnings.length === 0 ? `${locales.length} catalog locale(s) covered` : undefined, warnings };
|
|
808
|
+
});
|
|
809
|
+
await warnOnly("pull currency", async () => {
|
|
810
|
+
const manifest = await edge.manifest(edgeOf(config), config.projectKey);
|
|
811
|
+
if (manifest.locales.length === 0)
|
|
812
|
+
return { detail: "no published bundles yet" };
|
|
813
|
+
const { raw } = parseCatalog(path.resolve(dir, config.catalogPath));
|
|
814
|
+
const stale = [];
|
|
815
|
+
for (const l of manifest.locales) {
|
|
816
|
+
const bundle = await edge.bundle(edgeOf(config), config.projectKey, l.id);
|
|
817
|
+
const clone = structuredClone(raw);
|
|
818
|
+
if (mergeTranslations(clone, l.id, bundle.strings) > 0)
|
|
819
|
+
stale.push(l.id);
|
|
820
|
+
}
|
|
821
|
+
if (stale.length === 0)
|
|
822
|
+
return { detail: `catalog current with ${manifest.locales.length} published locale(s)` };
|
|
823
|
+
return { warnings: [`catalog fallbacks stale for ${stale.join(", ")} — run \`polycast pull\``] };
|
|
824
|
+
});
|
|
543
825
|
if (failures > 0) {
|
|
544
826
|
console.log(`\n ${S.fail} ${red(`${failures} check(s) failed`)}\n`);
|
|
545
827
|
process.exit(1);
|