polycast-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -0
- package/dist/api.d.ts +97 -0
- package/dist/api.js +59 -0
- package/dist/api.js.map +1 -0
- package/dist/commands.d.ts +27 -0
- package/dist/commands.js +612 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.js +55 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +123 -0
- package/dist/index.js.map +1 -0
- package/dist/keychain.d.ts +3 -0
- package/dist/keychain.js +60 -0
- package/dist/keychain.js.map +1 -0
- package/dist/scan.d.ts +27 -0
- package/dist/scan.js +164 -0
- package/dist/scan.js.map +1 -0
- package/dist/ui.d.ts +71 -0
- package/dist/ui.js +214 -0
- package/dist/ui.js.map +1 -0
- package/dist/util.d.ts +13 -0
- package/dist/util.js +61 -0
- package/dist/util.js.map +1 -0
- package/dist/xcstrings.d.ts +66 -0
- package/dist/xcstrings.js +110 -0
- package/dist/xcstrings.js.map +1 -0
- package/package.json +44 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { CliError, confirm, promptHidden, isCI } from "./util.js";
|
|
5
|
+
import { S, c, g, bold, dim, green, red, yellow, accent, header, section, kv, bar, pctLabel, flag, table, box, spin, success, note, relativeTime, } from "./ui.js";
|
|
6
|
+
import { DEFAULT_API, DEFAULT_EDGE, findConfig, requireConfig, writeConfig, ensureGitignored, stateDir, } from "./config.js";
|
|
7
|
+
import { admin, edge, project, requireAdminToken } from "./api.js";
|
|
8
|
+
import { storeToken, loadToken, clearToken } from "./keychain.js";
|
|
9
|
+
import { parseCatalog, mergeTranslations, writeCatalog, serializeCatalog, addMissingKeys } from "./xcstrings.js";
|
|
10
|
+
import { scanSwiftSources } from "./scan.js";
|
|
11
|
+
const apiOf = (config) => config?.endpoint ?? DEFAULT_API;
|
|
12
|
+
const edgeOf = (config) => config?.edgeEndpoint ?? DEFAULT_EDGE;
|
|
13
|
+
/** Resolve the project id for admin commands from polycast.json's key. */
|
|
14
|
+
async function resolveProject() {
|
|
15
|
+
const { config, dir } = requireConfig();
|
|
16
|
+
const token = requireAdminToken();
|
|
17
|
+
const { projectId } = await admin.resolveKey(apiOf(config), token, config.projectKey);
|
|
18
|
+
return { config, dir, token, projectId };
|
|
19
|
+
}
|
|
20
|
+
// ---- login / logout ----
|
|
21
|
+
export async function login(flags) {
|
|
22
|
+
const endpoint = flags.endpoint ?? findConfig()?.config.endpoint ?? DEFAULT_API;
|
|
23
|
+
let token;
|
|
24
|
+
if (flags.token || isCI()) {
|
|
25
|
+
// Headless: paste a personal access token (pcat_) or legacy admin token.
|
|
26
|
+
token = await promptHidden("Personal access token (pcat_…): ");
|
|
27
|
+
if (!token)
|
|
28
|
+
throw new CliError("no token entered", 2);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
token = await browserLogin(endpoint, flags.org);
|
|
32
|
+
}
|
|
33
|
+
const sp = spin("validating token");
|
|
34
|
+
await admin.listProjects(endpoint, token); // validates against the API
|
|
35
|
+
storeToken(token);
|
|
36
|
+
sp.succeed(`logged in — token stored in ${process.platform === "darwin" ? "your keychain" : "~/.config/polycast/token"}`);
|
|
37
|
+
}
|
|
38
|
+
/** Browser flow: localhost listener + {endpoint}/cli/authorize (Clerk
|
|
39
|
+
* sign-in page) which mints a PAT and redirects back with it. Falls back
|
|
40
|
+
* to paste when the server has no Clerk configured. */
|
|
41
|
+
async function browserLogin(endpoint, org) {
|
|
42
|
+
const { createServer } = await import("node:http");
|
|
43
|
+
const { exec } = await import("node:child_process");
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const server = createServer((req, res) => {
|
|
46
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
47
|
+
const token = url.searchParams.get("token");
|
|
48
|
+
if (url.pathname === "/callback" && token) {
|
|
49
|
+
res.writeHead(200, { "content-type": "text/html" });
|
|
50
|
+
res.end("<h3>Logged in — you can close this tab and return to the terminal.</h3>");
|
|
51
|
+
server.close();
|
|
52
|
+
resolve(token);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
res.writeHead(404);
|
|
56
|
+
res.end();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
server.listen(0, "127.0.0.1", () => {
|
|
60
|
+
const port = server.address().port;
|
|
61
|
+
const authorize = `${endpoint}/cli/authorize?port=${port}${org ? `&org=${org}` : ""}`;
|
|
62
|
+
console.log(`Opening ${authorize}`);
|
|
63
|
+
console.log(dim("(if the server has no Clerk configured, rerun with --token)"));
|
|
64
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
65
|
+
exec(`${opener} ${JSON.stringify(authorize)}`);
|
|
66
|
+
setTimeout(() => { server.close(); reject(new CliError("login timed out after 5 minutes")); }, 300_000).unref();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export async function whoami(flags) {
|
|
71
|
+
const endpoint = flags.endpoint ?? findConfig()?.config.endpoint ?? DEFAULT_API;
|
|
72
|
+
const token = requireAdminToken();
|
|
73
|
+
const res = await fetch(`${endpoint}/v1/admin/orgs/me`, {
|
|
74
|
+
headers: { authorization: `Bearer ${token}` },
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok)
|
|
77
|
+
throw new CliError("token invalid — run `polycast login`");
|
|
78
|
+
const me = await res.json();
|
|
79
|
+
if (flags.json) {
|
|
80
|
+
console.log(JSON.stringify(me, null, 2));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
header("whoami");
|
|
84
|
+
for (const org of me.orgs) {
|
|
85
|
+
console.log(` ${S.bullet} ${bold(org.name)} ${accent(org.role)} ${dim(`· ${org.plan} plan · ${org.id}`)}`);
|
|
86
|
+
}
|
|
87
|
+
if (me.orgs.length === 0)
|
|
88
|
+
console.log(` ${S.warn} ${yellow("no org memberships found")}`);
|
|
89
|
+
console.log();
|
|
90
|
+
}
|
|
91
|
+
export async function logout() {
|
|
92
|
+
clearToken();
|
|
93
|
+
console.log("logged out");
|
|
94
|
+
}
|
|
95
|
+
// ---- init ----
|
|
96
|
+
function detectCatalog(cwd) {
|
|
97
|
+
const hits = [];
|
|
98
|
+
const walk = (dir, depth) => {
|
|
99
|
+
if (depth > 4)
|
|
100
|
+
return;
|
|
101
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
102
|
+
if (entry.name.startsWith(".") || ["node_modules", "Pods", "build", "DerivedData", ".build"].includes(entry.name))
|
|
103
|
+
continue;
|
|
104
|
+
const full = path.join(dir, entry.name);
|
|
105
|
+
if (entry.isDirectory())
|
|
106
|
+
walk(full, depth + 1);
|
|
107
|
+
else if (entry.name.endsWith(".xcstrings"))
|
|
108
|
+
hits.push(full);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
walk(cwd, 0);
|
|
112
|
+
if (hits.length === 1)
|
|
113
|
+
return path.relative(cwd, hits[0]);
|
|
114
|
+
if (hits.length > 1) {
|
|
115
|
+
const preferred = hits.find((h) => path.basename(h) === "Localizable.xcstrings");
|
|
116
|
+
return preferred ? path.relative(cwd, preferred) : null;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
function detectProjectName(cwd) {
|
|
121
|
+
const xcodeproj = readdirSync(cwd).find((f) => f.endsWith(".xcodeproj"));
|
|
122
|
+
if (xcodeproj)
|
|
123
|
+
return xcodeproj.replace(/\.xcodeproj$/, "");
|
|
124
|
+
const pkg = path.join(cwd, "Package.swift");
|
|
125
|
+
if (existsSync(pkg)) {
|
|
126
|
+
const m = /name:\s*"([^"]+)"/.exec(readFileSync(pkg, "utf8"));
|
|
127
|
+
if (m)
|
|
128
|
+
return m[1];
|
|
129
|
+
}
|
|
130
|
+
return path.basename(cwd);
|
|
131
|
+
}
|
|
132
|
+
export async function init(flags) {
|
|
133
|
+
const cwd = process.cwd();
|
|
134
|
+
const endpoint = flags.endpoint ?? DEFAULT_API;
|
|
135
|
+
const existing = findConfig(cwd);
|
|
136
|
+
if (existing) {
|
|
137
|
+
// Idempotent: verify rather than recreate.
|
|
138
|
+
const token = loadToken();
|
|
139
|
+
console.log(`polycast.json found (project key ${existing.config.projectKey.slice(0, 12)}…)`);
|
|
140
|
+
if (token) {
|
|
141
|
+
const resolved = await admin.resolveKey(apiOf(existing.config), token, existing.config.projectKey);
|
|
142
|
+
console.log(` ${S.ok} verified: ${bold(resolved.name)} ${dim(`(${resolved.projectId})`)}`);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
console.log(dim("run `polycast login` + `polycast doctor` for a full verification"));
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const token = requireAdminToken();
|
|
150
|
+
const catalogPath = flags.catalog ?? detectCatalog(cwd);
|
|
151
|
+
if (!catalogPath) {
|
|
152
|
+
throw new CliError("could not find a unique .xcstrings catalog — pass --catalog <path>", 2);
|
|
153
|
+
}
|
|
154
|
+
parseCatalog(path.resolve(cwd, catalogPath)); // fail fast if unparseable
|
|
155
|
+
const name = flags.name ?? detectProjectName(cwd);
|
|
156
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63) || "app";
|
|
157
|
+
if (!(await confirm(`Create Polycast project "${name}" (slug: ${slug})?`, flags.yes))) {
|
|
158
|
+
throw new CliError("aborted", 2);
|
|
159
|
+
}
|
|
160
|
+
const created = await admin.createProject(endpoint, token, { name, slug });
|
|
161
|
+
const key = await admin.createKey(endpoint, token, created.id, "pc_live_");
|
|
162
|
+
const config = {
|
|
163
|
+
endpoint, edgeEndpoint: DEFAULT_EDGE, projectKey: key.key, catalogPath,
|
|
164
|
+
};
|
|
165
|
+
writeConfig(cwd, config);
|
|
166
|
+
ensureGitignored(cwd);
|
|
167
|
+
success(`project "${name}" created — polycast.json written`);
|
|
168
|
+
console.log(` ${bold("Two integration touches:")}\n`);
|
|
169
|
+
console.log(`1. Add the build plugin to your app target (Package.swift):
|
|
170
|
+
|
|
171
|
+
.target(
|
|
172
|
+
name: "${name}",
|
|
173
|
+
plugins: [.plugin(name: "PolycastBuildPlugin", package: "polycast-swift")]
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
2. Start Polycast in your App init:
|
|
177
|
+
|
|
178
|
+
import Polycast
|
|
179
|
+
|
|
180
|
+
init() {
|
|
181
|
+
Polycast.configure(endpoint: URL(string: "${edgeOf(config)}")!)
|
|
182
|
+
Polycast.start(key: "${key.key}")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
Then: build once (the plugin pushes your strings), \`polycast langs add <locales>\`, \`polycast publish --wait\`.`);
|
|
186
|
+
}
|
|
187
|
+
// ---- langs ----
|
|
188
|
+
/** App Store locales for `langs add all`. */
|
|
189
|
+
const APP_STORE_LOCALES = [
|
|
190
|
+
"ar", "ca", "zh-Hans", "zh-Hant", "hr", "cs", "da", "nl", "en-AU", "en-CA",
|
|
191
|
+
"en-GB", "fi", "fr", "fr-CA", "de", "el", "he", "hi", "hu", "id", "it",
|
|
192
|
+
"ja", "ko", "ms", "no", "pl", "pt-BR", "pt-PT", "ro", "ru", "sk", "es",
|
|
193
|
+
"es-419", "sv", "th", "tr", "uk", "vi",
|
|
194
|
+
];
|
|
195
|
+
function nativeName(locale) {
|
|
196
|
+
try {
|
|
197
|
+
const name = new Intl.DisplayNames([locale], { type: "language" }).of(locale);
|
|
198
|
+
if (name && name !== locale)
|
|
199
|
+
return name[0].toUpperCase() + name.slice(1);
|
|
200
|
+
}
|
|
201
|
+
catch { /* fall through */ }
|
|
202
|
+
return locale;
|
|
203
|
+
}
|
|
204
|
+
export async function langs(args, flags) {
|
|
205
|
+
const { config, token, projectId } = await resolveProject();
|
|
206
|
+
const [sub, ...rest] = args;
|
|
207
|
+
if (!sub) {
|
|
208
|
+
const status = await admin.status(apiOf(config), token, projectId);
|
|
209
|
+
if (flags.json) {
|
|
210
|
+
console.log(JSON.stringify(status.locales, null, 2));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
header(status.project.name, `${status.totalStrings} source strings ${g.sep} ${status.locales.filter((l) => l.enabled).length} languages`);
|
|
214
|
+
console.log(table(["", "locale", "", "coverage", "", "drafts", "review"], status.locales.filter((l) => l.enabled).map((l) => [
|
|
215
|
+
flag(l.locale),
|
|
216
|
+
c.accent2(l.locale),
|
|
217
|
+
c.muted(l.nativeName),
|
|
218
|
+
bar(l.coveragePct),
|
|
219
|
+
pctLabel(l.coveragePct),
|
|
220
|
+
l.drafts > 0 ? c.danger(String(l.drafts)) : c.muted("0"),
|
|
221
|
+
l.needsReview > 0 ? c.negative(String(l.needsReview)) : c.muted("0"),
|
|
222
|
+
])));
|
|
223
|
+
console.log();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (sub === "add") {
|
|
227
|
+
const ids = rest[0] === "all" ? APP_STORE_LOCALES : rest;
|
|
228
|
+
if (ids.length === 0)
|
|
229
|
+
throw new CliError("usage: polycast langs add <locale...>|all", 2);
|
|
230
|
+
const status = await admin.status(apiOf(config), token, projectId);
|
|
231
|
+
const newOnes = ids.filter((id) => !status.locales.some((l) => l.locale === id && l.enabled));
|
|
232
|
+
const willDraft = newOnes.length * status.totalStrings;
|
|
233
|
+
if (newOnes.length === 0) {
|
|
234
|
+
console.log("all requested languages already enabled");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (!(await confirm(`Enable ${newOnes.join(", ")} — ${willDraft} string(s) will be AI-drafted. Continue?`, flags.yes))) {
|
|
238
|
+
throw new CliError("aborted", 2);
|
|
239
|
+
}
|
|
240
|
+
const sp = spin(`enabling ${newOnes.map((l) => `${flag(l)} ${l}`).join(" ")}`);
|
|
241
|
+
const result = await admin.putLocales(apiOf(config), token, projectId, {
|
|
242
|
+
locales: ids.map((id) => ({ id, nativeName: nativeName(id), enabled: true })),
|
|
243
|
+
});
|
|
244
|
+
sp.succeed(`enabled ${bold(String(ids.length))} language(s) — ${accent(String(result.draftJobsEnqueued))} draft job(s) queued`);
|
|
245
|
+
note("drafting runs in the background — `polycast publish --wait` when you're ready");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (sub === "remove") {
|
|
249
|
+
if (rest.length === 0)
|
|
250
|
+
throw new CliError("usage: polycast langs remove <locale...>", 2);
|
|
251
|
+
await admin.putLocales(apiOf(config), token, projectId, {
|
|
252
|
+
locales: rest.map((id) => ({ id, nativeName: nativeName(id), enabled: false })),
|
|
253
|
+
});
|
|
254
|
+
console.log(`disabled: ${rest.join(", ")} (bundles stop being served on next publish)`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
throw new CliError(`unknown subcommand: langs ${sub}`, 2);
|
|
258
|
+
}
|
|
259
|
+
// ---- push ----
|
|
260
|
+
export async function push(flags) {
|
|
261
|
+
const { config, dir } = requireConfig();
|
|
262
|
+
const catalogFile = path.resolve(dir, config.catalogPath);
|
|
263
|
+
// Zero-config capture: PText / Polycast.string literals in Swift
|
|
264
|
+
// sources are merged into the catalog automatically — write code,
|
|
265
|
+
// run push, done.
|
|
266
|
+
const scanned = scanSwiftSources(dir, config.scanCalls ?? []);
|
|
267
|
+
{
|
|
268
|
+
const { raw } = parseCatalog(catalogFile);
|
|
269
|
+
const added = addMissingKeys(raw, scanned);
|
|
270
|
+
if (added.length > 0) {
|
|
271
|
+
writeCatalog(catalogFile, raw);
|
|
272
|
+
console.log(`\n ${S.ok} captured ${bold(String(added.length))} new string(s) from source ${c.muted(`→ ${config.catalogPath}`)}`);
|
|
273
|
+
for (const key of added.slice(0, 8))
|
|
274
|
+
console.log(` ${c.positive("+")} ${c.text(key)}`);
|
|
275
|
+
if (added.length > 8)
|
|
276
|
+
console.log(` ${c.muted(`… and ${added.length - 8} more`)}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const { sourceLocale, strings } = parseCatalog(catalogFile);
|
|
280
|
+
console.log(`\n ${S.diamond} ${c.text(`${strings.length} strings`)} ${c.muted(`(catalog + source scan) from ${config.catalogPath}`)}`);
|
|
281
|
+
const stateFile = path.join(stateDir(dir), "last-push.json");
|
|
282
|
+
const previous = existsSync(stateFile)
|
|
283
|
+
? (JSON.parse(readFileSync(stateFile, "utf8")).digests ?? {})
|
|
284
|
+
: {};
|
|
285
|
+
const changedOrNew = strings.filter((s) => previous[s.key] !== s.digest);
|
|
286
|
+
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`);
|
|
288
|
+
if (changedOrNew.length === 0)
|
|
289
|
+
return;
|
|
290
|
+
const sp = spin("pushing source strings");
|
|
291
|
+
const result = await project.pushStrings(apiOf(config), config.projectKey, {
|
|
292
|
+
schemaVersion: 1,
|
|
293
|
+
projectKey: config.projectKey,
|
|
294
|
+
sourceLocale,
|
|
295
|
+
strings: changedOrNew.map(({ key, comment, pluralShape, digest }) => ({
|
|
296
|
+
key, ...(comment !== undefined ? { comment } : {}),
|
|
297
|
+
...(pluralShape ? { pluralShape } : {}), digest,
|
|
298
|
+
})),
|
|
299
|
+
});
|
|
300
|
+
const digests = { ...previous };
|
|
301
|
+
for (const s of strings)
|
|
302
|
+
digests[s.key] = s.digest;
|
|
303
|
+
writeFileSync(stateFile, JSON.stringify({ digests }, null, 2));
|
|
304
|
+
sp.succeed(`pushed — ${accent(String(result.draftJobsEnqueued))} draft job(s) queued across enabled languages`);
|
|
305
|
+
}
|
|
306
|
+
// ---- pull ----
|
|
307
|
+
export async function pull(flags) {
|
|
308
|
+
const { config, dir } = requireConfig();
|
|
309
|
+
const catalogFile = path.resolve(dir, config.catalogPath);
|
|
310
|
+
const { raw } = parseCatalog(catalogFile);
|
|
311
|
+
const before = serializeCatalog(raw);
|
|
312
|
+
const manifest = await edge.manifest(edgeOf(config), config.projectKey);
|
|
313
|
+
const wanted = flags.locales?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
314
|
+
const locales = manifest.locales.map((l) => l.id)
|
|
315
|
+
.filter((id) => !wanted || wanted.includes(id));
|
|
316
|
+
let changed = 0;
|
|
317
|
+
for (const locale of locales) {
|
|
318
|
+
const bundle = await edge.bundle(edgeOf(config), config.projectKey, locale);
|
|
319
|
+
changed += mergeTranslations(raw, locale, bundle.strings);
|
|
320
|
+
}
|
|
321
|
+
const after = serializeCatalog(raw);
|
|
322
|
+
if (flags.check) {
|
|
323
|
+
if (after !== before) {
|
|
324
|
+
console.error(` ${S.fail} ${red(`catalog is stale — pulling would change ${bold(String(changed))} value(s) across ${locales.length} language(s)`)}`);
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
success("catalog fallbacks are up to date");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (after === before) {
|
|
331
|
+
note("catalog already up to date");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
writeCatalog(catalogFile, raw);
|
|
335
|
+
success(`merged ${locales.map(flag).join(" ")} ${locales.join(", ")} — ${changed} value(s) updated`);
|
|
336
|
+
}
|
|
337
|
+
// ---- publish ----
|
|
338
|
+
export async function publish(args, flags) {
|
|
339
|
+
const { config, token, projectId } = await resolveProject();
|
|
340
|
+
const api = apiOf(config);
|
|
341
|
+
let targets = args;
|
|
342
|
+
if (flags.wait) {
|
|
343
|
+
const sp = spin("waiting for drafting to finish");
|
|
344
|
+
const deadline = Date.now() + 10 * 60_000;
|
|
345
|
+
for (;;) {
|
|
346
|
+
const status = await admin.status(api, token, projectId);
|
|
347
|
+
const pending = status.locales.filter((l) => l.enabled && (targets.length === 0 || targets.includes(l.locale)) &&
|
|
348
|
+
l.published + l.drafts + l.needsReview < status.totalStrings);
|
|
349
|
+
if (pending.length === 0) {
|
|
350
|
+
sp.succeed("drafting complete");
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
if (Date.now() > deadline) {
|
|
354
|
+
sp.fail("timed out waiting for drafts");
|
|
355
|
+
throw new CliError(`still pending: ${pending.map((l) => l.locale).join(", ")}`);
|
|
356
|
+
}
|
|
357
|
+
const done = status.locales.filter((l) => l.enabled).length - pending.length;
|
|
358
|
+
sp.update(`drafting ${done}/${status.locales.filter((l) => l.enabled).length} languages ready ${dim(pending.map((l) => l.locale).join(" "))}`);
|
|
359
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const status = await admin.status(api, token, projectId);
|
|
363
|
+
if (targets.length === 0) {
|
|
364
|
+
targets = status.locales.filter((l) => l.enabled && l.drafts > 0).map((l) => l.locale);
|
|
365
|
+
if (targets.length === 0) {
|
|
366
|
+
console.log("nothing to publish — no pending drafts");
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const review = status.locales
|
|
371
|
+
.filter((l) => targets.includes(l.locale) && l.needsReview > 0);
|
|
372
|
+
if (review.length > 0) {
|
|
373
|
+
console.log(` ${S.warn} ${yellow(`${review.map((l) => `${l.locale} (${l.needsReview})`).join(", ")} have needs-review strings — they stay unpublished`)}`);
|
|
374
|
+
}
|
|
375
|
+
if (!(await confirm(`Publish ${targets.join(", ")}?`, flags.yes)))
|
|
376
|
+
throw new CliError("aborted", 2);
|
|
377
|
+
const sp2 = spin(`publishing ${targets.join(", ")}`);
|
|
378
|
+
const result = await admin.publish(api, token, projectId, targets);
|
|
379
|
+
sp2.succeed("published");
|
|
380
|
+
console.log();
|
|
381
|
+
console.log(box(result.published.map((p) => `${flag(p.locale)} ${c.accent2(p.locale.padEnd(7))} ${S.arrow} ${c.text(`bundle v${p.bundleVersion}`)}`), "live on the CDN", c.accent));
|
|
382
|
+
note("devices pick this up on next app foreground");
|
|
383
|
+
console.log();
|
|
384
|
+
}
|
|
385
|
+
// ---- status ----
|
|
386
|
+
export async function status(flags) {
|
|
387
|
+
const { config, token, projectId } = await resolveProject();
|
|
388
|
+
const s = await admin.status(apiOf(config), token, projectId);
|
|
389
|
+
if (flags.json) {
|
|
390
|
+
console.log(JSON.stringify(s, null, 2));
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
const enabled = s.locales.filter((l) => l.enabled);
|
|
394
|
+
const avgCoverage = enabled.length
|
|
395
|
+
? Math.round(enabled.reduce((a, l) => a + l.coveragePct, 0) / enabled.length) : 0;
|
|
396
|
+
const draftCount = enabled.reduce((a, l) => a + l.drafts, 0);
|
|
397
|
+
const reviewCount = enabled.reduce((a, l) => a + l.needsReview, 0);
|
|
398
|
+
const latest = enabled.map((l) => l.lastPublish).filter(Boolean)
|
|
399
|
+
.sort((a, b) => (a.at < b.at ? 1 : -1))[0];
|
|
400
|
+
const failed = s.deadLetter.reduce((a, d) => a + d.n, 0);
|
|
401
|
+
header(s.project.name, `${config.projectKey.slice(0, 16)}… ${g.sep} source ${s.project.sourceLocale}`);
|
|
402
|
+
kv("coverage", `${bar(avgCoverage, 22)} ${pctLabel(avgCoverage)} ${c.muted(`across ${enabled.length} languages`)}`);
|
|
403
|
+
kv("strings", `${c.text(String(s.totalStrings))} ${c.muted("source")} ${draftCount > 0 ? c.danger(`${draftCount} drafts pending`) : c.muted("no drafts pending")} ${reviewCount > 0 ? c.negative(`${reviewCount} need review`) : c.muted("0 need review")}`);
|
|
404
|
+
kv("delivery", latest
|
|
405
|
+
? `${c.text(`bundle v${latest.bundleVersion}`)} ${c.positive("live")} ${c.muted(`${g.sep} published ${relativeTime(latest.at)}`)}`
|
|
406
|
+
: c.muted("nothing published yet"));
|
|
407
|
+
kv("jobs", failed > 0 ? c.negative(`${failed} failed — inspect with status --json`) : c.positive("all healthy"));
|
|
408
|
+
section("languages");
|
|
409
|
+
console.log(table(["", "locale", "coverage", "", "drafts", "review", "bundle"], enabled.map((l) => [
|
|
410
|
+
flag(l.locale),
|
|
411
|
+
c.accent2(l.locale),
|
|
412
|
+
bar(l.coveragePct),
|
|
413
|
+
pctLabel(l.coveragePct),
|
|
414
|
+
l.drafts > 0 ? c.danger(String(l.drafts)) : c.muted("0"),
|
|
415
|
+
l.needsReview > 0 ? c.negative(String(l.needsReview)) : c.muted("0"),
|
|
416
|
+
l.lastPublish ? c.muted(`v${l.lastPublish.bundleVersion}`) : c.muted("—"),
|
|
417
|
+
])));
|
|
418
|
+
console.log();
|
|
419
|
+
}
|
|
420
|
+
if (s.deadLetter.length > 0)
|
|
421
|
+
process.exit(1);
|
|
422
|
+
}
|
|
423
|
+
// ---- doctor ----
|
|
424
|
+
export async function doctor() {
|
|
425
|
+
header("doctor", "local + remote checks");
|
|
426
|
+
let failures = 0;
|
|
427
|
+
const check = async (label, fn) => {
|
|
428
|
+
const sp = spin(label);
|
|
429
|
+
try {
|
|
430
|
+
const detail = await fn();
|
|
431
|
+
sp.succeed(`${label}${detail ? dim(` ${detail}`) : ""}`);
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
failures++;
|
|
435
|
+
sp.fail(label);
|
|
436
|
+
console.log(` ${dim("fix:")} ${err.message}`);
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
const found = findConfig();
|
|
440
|
+
await check("polycast.json present and valid", () => {
|
|
441
|
+
if (!found)
|
|
442
|
+
throw new Error("run `polycast init`");
|
|
443
|
+
return found.config.projectKey.slice(0, 12) + "…";
|
|
444
|
+
});
|
|
445
|
+
if (!found) {
|
|
446
|
+
process.exit(1);
|
|
447
|
+
}
|
|
448
|
+
const { config, dir } = found;
|
|
449
|
+
await check("catalog parses", () => {
|
|
450
|
+
const { strings } = parseCatalog(path.resolve(dir, config.catalogPath));
|
|
451
|
+
return `${strings.length} strings`;
|
|
452
|
+
});
|
|
453
|
+
await check("build plugin attached", () => {
|
|
454
|
+
const candidates = [path.join(dir, "Package.swift"),
|
|
455
|
+
...readdirSync(dir).filter((f) => f.endsWith(".xcodeproj")).map((f) => path.join(dir, f, "project.pbxproj"))];
|
|
456
|
+
const hit = candidates.some((f) => existsSync(f) && readFileSync(f, "utf8").includes("PolycastBuildPlugin"));
|
|
457
|
+
if (!hit)
|
|
458
|
+
throw new Error("add PolycastBuildPlugin to your target (see `polycast init` output); if using the plugin, strings push automatically on build");
|
|
459
|
+
});
|
|
460
|
+
await check(".polycast/ gitignored", () => {
|
|
461
|
+
try {
|
|
462
|
+
// Respects parent/global gitignores, unlike a flat file check.
|
|
463
|
+
execSync("git check-ignore -q .polycast", { cwd: dir, stdio: "ignore" });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
catch { /* not ignored, or not a git repo — fall back */ }
|
|
467
|
+
const gi = path.join(dir, ".gitignore");
|
|
468
|
+
if (!existsSync(gi) || !readFileSync(gi, "utf8").includes(".polycast")) {
|
|
469
|
+
throw new Error("add `.polycast/` to .gitignore");
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
await check("project key authenticates", async () => {
|
|
473
|
+
await project.pushStrings(apiOf(config), config.projectKey, {
|
|
474
|
+
schemaVersion: 1, projectKey: config.projectKey,
|
|
475
|
+
sourceLocale: "en", strings: [],
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
await check("admin token valid", async () => {
|
|
479
|
+
const token = loadToken();
|
|
480
|
+
if (!token)
|
|
481
|
+
return "not logged in (fine for CI; needed for langs/publish/status)";
|
|
482
|
+
await admin.resolveKey(apiOf(config), token, config.projectKey);
|
|
483
|
+
});
|
|
484
|
+
await check("edge reachable", async () => {
|
|
485
|
+
const manifest = await edge.manifest(edgeOf(config), config.projectKey);
|
|
486
|
+
return `${manifest.locales.length} locale(s) live`;
|
|
487
|
+
});
|
|
488
|
+
await check("ETag/304 round-trip", async () => {
|
|
489
|
+
const manifest = await edge.manifest(edgeOf(config), config.projectKey);
|
|
490
|
+
const first = manifest.locales[0];
|
|
491
|
+
if (!first)
|
|
492
|
+
return "no bundles published yet — skipped";
|
|
493
|
+
const res = await edge.raw(edgeOf(config), config.projectKey, first.id);
|
|
494
|
+
const etag = res.headers.get("etag");
|
|
495
|
+
if (!etag)
|
|
496
|
+
throw new Error("edge did not return an ETag");
|
|
497
|
+
const cached = await edge.raw(edgeOf(config), config.projectKey, first.id, { "If-None-Match": etag });
|
|
498
|
+
if (cached.status !== 304)
|
|
499
|
+
throw new Error(`expected 304, got ${cached.status}`);
|
|
500
|
+
});
|
|
501
|
+
if (failures > 0) {
|
|
502
|
+
console.log(`\n ${S.fail} ${red(`${failures} check(s) failed`)}\n`);
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
success("all checks passed");
|
|
506
|
+
}
|
|
507
|
+
// ---- shadow ----
|
|
508
|
+
const SHADOW_CONTENT = `// Generated by \`polycast shadow\`: routes every plain \`Text("…")\` in
|
|
509
|
+
// this module through Polycast (OTA-first, bundled fallback) — including
|
|
510
|
+
// custom components that render via unqualified Text. Opt out per call
|
|
511
|
+
// site with SwiftUI.Text(...) or Text(verbatim:). Do not edit.
|
|
512
|
+
import PolycastSDK
|
|
513
|
+
typealias Text = PText
|
|
514
|
+
`;
|
|
515
|
+
/** Plant Polycast+Shadow.swift into each source module so plain Text —
|
|
516
|
+
* and any design-system component built on it — renders OTA. */
|
|
517
|
+
export async function shadow(args, _flags) {
|
|
518
|
+
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];
|
|
548
|
+
}
|
|
549
|
+
let planted = 0;
|
|
550
|
+
for (const target of targets) {
|
|
551
|
+
const file = path.join(target, "Polycast+Shadow.swift");
|
|
552
|
+
if (existsSync(file))
|
|
553
|
+
continue;
|
|
554
|
+
writeFileSync(file, SHADOW_CONTENT);
|
|
555
|
+
console.log(` ${S.ok} ${path.relative(dir, file)}`);
|
|
556
|
+
planted++;
|
|
557
|
+
}
|
|
558
|
+
if (planted === 0) {
|
|
559
|
+
note("all source modules already shadowed");
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
success(`planted ${planted} shadow file(s) — plain Text now renders OTA in those modules`);
|
|
563
|
+
note("using XcodeGen? re-run `xcodegen generate` so the new files join the project");
|
|
564
|
+
}
|
|
565
|
+
// ---- gen ----
|
|
566
|
+
function swiftIdent(part) {
|
|
567
|
+
const cleaned = part.replace(/[^A-Za-z0-9]+(.)?/g, (_, c) => c ? c.toUpperCase() : "");
|
|
568
|
+
const ident = /^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned;
|
|
569
|
+
const keywords = new Set(["default", "case", "class", "enum", "struct", "import", "let", "var", "in", "for", "self"]);
|
|
570
|
+
return keywords.has(ident) ? `\`${ident}\`` : (ident || "_empty");
|
|
571
|
+
}
|
|
572
|
+
export async function gen(flags) {
|
|
573
|
+
const { config, dir } = requireConfig();
|
|
574
|
+
const { strings } = parseCatalog(path.resolve(dir, config.catalogPath));
|
|
575
|
+
const outPath = path.resolve(dir, flags.output ?? "Sources/Generated/PolycastKeys.swift");
|
|
576
|
+
const root = { children: new Map(), leaves: [] };
|
|
577
|
+
for (const s of strings) {
|
|
578
|
+
const parts = s.key.split(".");
|
|
579
|
+
if (parts.length > 1 && parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p))) {
|
|
580
|
+
let node = root;
|
|
581
|
+
for (const part of parts.slice(0, -1)) {
|
|
582
|
+
if (!node.children.has(part))
|
|
583
|
+
node.children.set(part, { children: new Map(), leaves: [] });
|
|
584
|
+
node = node.children.get(part);
|
|
585
|
+
}
|
|
586
|
+
node.leaves.push({ name: swiftIdent(parts[parts.length - 1]), key: s.key });
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
root.leaves.push({ name: swiftIdent(s.key.split(/\s+/).slice(0, 5).join(" ")), key: s.key });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const emit = (node, name, indent) => {
|
|
593
|
+
const lines = [`${indent}public enum ${swiftIdent(name)} {`];
|
|
594
|
+
for (const leaf of node.leaves.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
595
|
+
lines.push(`${indent} public static let ${leaf.name} = ${JSON.stringify(leaf.key)}`);
|
|
596
|
+
}
|
|
597
|
+
for (const [childName, child] of [...node.children].sort()) {
|
|
598
|
+
lines.push(emit(child, childName, indent + " "));
|
|
599
|
+
}
|
|
600
|
+
lines.push(`${indent}}`);
|
|
601
|
+
return lines.join("\n");
|
|
602
|
+
};
|
|
603
|
+
const out = `// AUTO-GENERATED by \`polycast gen\` from ${config.catalogPath} — do not edit.
|
|
604
|
+
// Use with Polycast: PText(L.paywall.title) / Polycast.string(L.paywall.title)
|
|
605
|
+
|
|
606
|
+
${emit(root, "L", "")}
|
|
607
|
+
`;
|
|
608
|
+
mkdirSync(path.dirname(outPath), { recursive: true });
|
|
609
|
+
writeFileSync(outPath, out);
|
|
610
|
+
success(`wrote ${path.relative(dir, outPath)} ${dim(`(${strings.length} keys)`)}`);
|
|
611
|
+
}
|
|
612
|
+
//# sourceMappingURL=commands.js.map
|