@lingo.dev/cli 1.0.1 → 1.0.3
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/bin.js +108 -0
- package/dist/flush-telemetry.js +38 -0
- package/dist/index.js +3 -0
- package/dist/renderer-D2iDOMA6.js +1533 -0
- package/dist/server-DGIsMSAq.js +847 -0
- package/dist/update-RHUBOb93.js +816 -0
- package/package.json +2 -2
|
@@ -0,0 +1,1533 @@
|
|
|
1
|
+
import { C as discoverLocales, S as writeLocaleFile, T as tryReadFile, _ as runExtractionPipeline, b as mergeEntries, c as formatCheckResult, d as computeSourceStatus, f as formatStatusTable, g as generateTypes, h as toApiPayload, l as runChecks, m as planLocalization, n as UpdateService, p as applyTranslations, s as VERSION, t as UpdateError, u as computeLocaleStatus, v as toLocaleEntries, w as findSourceFiles, x as readLocaleFile, y as getActiveEntries } from "./update-RHUBOb93.js";
|
|
2
|
+
import { Args, CliConfig, Command, Options } from "@effect/cli";
|
|
3
|
+
import { Console, Context, Data, Effect, Layer, Option, pipe } from "effect";
|
|
4
|
+
import pc from "picocolors";
|
|
5
|
+
import * as fs$1 from "node:fs/promises";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import { createClient } from "@supabase/supabase-js";
|
|
10
|
+
import cfonts from "cfonts";
|
|
11
|
+
import logUpdate from "log-update";
|
|
12
|
+
import * as clack from "@clack/prompts";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import * as ci from "ci-info";
|
|
15
|
+
import * as crypto from "node:crypto";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
//#region src/services/cli-options.ts
|
|
18
|
+
/**
|
|
19
|
+
* Shared CLI option definitions used across extract, localize, status, and ship commands.
|
|
20
|
+
*/
|
|
21
|
+
const sourceLocaleOption = Options.text("source-locale").pipe(Options.withDescription("Source locale code"), Options.withDefault("en"));
|
|
22
|
+
const targetLocaleOption = pipe(Options.text("target-locale"), Options.withDescription("Target locale(s) to localize"), Options.repeated);
|
|
23
|
+
const srcOption = Options.text("src").pipe(Options.withDescription("Source directory to scan"), Options.withDefault("src"));
|
|
24
|
+
const outOption = Options.text("out").pipe(Options.withDescription("Output directory for locale files"), Options.withDefault("locales"));
|
|
25
|
+
const jsonOption = Options.boolean("json").pipe(Options.withDescription("Machine-readable JSON output for CI"));
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/commands/extract.ts
|
|
28
|
+
const check = Options.boolean("check").pipe(Options.withDescription("Check if JSONC is up to date (CI gate)"));
|
|
29
|
+
const types = Options.text("types").pipe(Options.withDescription("Output path for generated type declarations"), Options.withDefault("lingo.d.ts"));
|
|
30
|
+
const extractCommand = Command.make("extract", {
|
|
31
|
+
check,
|
|
32
|
+
src: srcOption,
|
|
33
|
+
sourceLocale: sourceLocaleOption,
|
|
34
|
+
out: outOption,
|
|
35
|
+
types
|
|
36
|
+
}, ({ check, src, sourceLocale, out, types: typesOut }) => Effect.gen(function* () {
|
|
37
|
+
const cwd = process.cwd();
|
|
38
|
+
const srcDir = path.resolve(cwd, src);
|
|
39
|
+
const outDir = path.resolve(cwd, out);
|
|
40
|
+
const localePath = path.join(outDir, `${sourceLocale}.jsonc`);
|
|
41
|
+
const typesPath = path.resolve(cwd, typesOut);
|
|
42
|
+
const filePaths = yield* Effect.tryPromise(() => findSourceFiles(srcDir));
|
|
43
|
+
if (filePaths.length === 0) {
|
|
44
|
+
yield* Console.log(`No .ts/.tsx files found in ${src}/`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const { messages, warnings, collisions } = runExtractionPipeline(yield* Effect.tryPromise(() => Promise.all(filePaths.map(async (f) => ({
|
|
48
|
+
code: await fs$1.readFile(f, "utf-8"),
|
|
49
|
+
filePath: path.relative(cwd, f)
|
|
50
|
+
})))));
|
|
51
|
+
for (const w of warnings) yield* Console.log(` warn: ${w.src} - ${w.message}`);
|
|
52
|
+
if (collisions.length > 0) {
|
|
53
|
+
for (const c of collisions) {
|
|
54
|
+
yield* Console.error(` collision: key "${c.key}" ->`);
|
|
55
|
+
for (const e of c.entries) yield* Console.error(` "${e.source}" at ${e.src}`);
|
|
56
|
+
}
|
|
57
|
+
return yield* Effect.die(/* @__PURE__ */ new Error(`${collisions.length} hash collision(s) detected. Add context strings to disambiguate.`));
|
|
58
|
+
}
|
|
59
|
+
const entries = toLocaleEntries(messages);
|
|
60
|
+
const existingContent = yield* Effect.tryPromise(() => tryReadFile(localePath));
|
|
61
|
+
const existing = existingContent ? readLocaleFile(existingContent) : { entries: [] };
|
|
62
|
+
const merged = mergeEntries(existing, entries);
|
|
63
|
+
const jsonc = writeLocaleFile(merged);
|
|
64
|
+
const typesContent = generateTypes(merged);
|
|
65
|
+
if (check) {
|
|
66
|
+
const currentJsonc = existingContent ?? "";
|
|
67
|
+
const currentTypes = (yield* Effect.tryPromise(() => tryReadFile(typesPath))) ?? "";
|
|
68
|
+
if (jsonc !== currentJsonc || typesContent !== currentTypes) {
|
|
69
|
+
yield* Console.error("JSONC is stale. Run `lingo extract` to update.");
|
|
70
|
+
return yield* Effect.die(/* @__PURE__ */ new Error("Stale JSONC"));
|
|
71
|
+
}
|
|
72
|
+
yield* Console.log("Up to date.");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
yield* Effect.tryPromise(() => fs$1.mkdir(outDir, { recursive: true }));
|
|
76
|
+
yield* Effect.tryPromise(() => fs$1.writeFile(localePath, jsonc));
|
|
77
|
+
yield* Effect.tryPromise(() => fs$1.writeFile(typesPath, typesContent));
|
|
78
|
+
const newCount = entries.filter((e) => !existing.entries.some((ex) => ex.key === e.key)).length;
|
|
79
|
+
const orphanCount = merged.entries.filter((e) => e.metadata.orphan).length;
|
|
80
|
+
yield* Console.log(`Extracted ${messages.length} strings (${newCount} new, ${orphanCount} orphaned) -> ${path.relative(cwd, localePath)}`);
|
|
81
|
+
})).pipe(Command.withDescription("Extract translatable strings from source files"));
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/services/supabase.ts
|
|
84
|
+
const supabase = createClient(process.env.LINGO_SUPABASE_URL ?? "https://nlugbbdqxnqwhydszieg.supabase.co", process.env.LINGO_SUPABASE_ANON_KEY ?? "sb_publishable_ZZH1cgFbkmb7Y0H482RFmA_jsvBxc3e", { auth: {
|
|
85
|
+
persistSession: false,
|
|
86
|
+
autoRefreshToken: false
|
|
87
|
+
} });
|
|
88
|
+
const sendOtp = (email) => Effect.tryPromise({
|
|
89
|
+
try: async () => {
|
|
90
|
+
const { error } = await supabase.auth.signInWithOtp({
|
|
91
|
+
email,
|
|
92
|
+
options: { shouldCreateUser: true }
|
|
93
|
+
});
|
|
94
|
+
if (error) throw error;
|
|
95
|
+
},
|
|
96
|
+
catch: (e) => new SupabaseError({
|
|
97
|
+
cause: e,
|
|
98
|
+
message: `Failed to send code to ${email}. Check the email address and try again.`
|
|
99
|
+
})
|
|
100
|
+
});
|
|
101
|
+
const verifyOtp = (email, code) => Effect.tryPromise({
|
|
102
|
+
try: async () => {
|
|
103
|
+
const { data, error } = await supabase.auth.verifyOtp({
|
|
104
|
+
email,
|
|
105
|
+
token: code,
|
|
106
|
+
type: "email"
|
|
107
|
+
});
|
|
108
|
+
if (error) throw error;
|
|
109
|
+
if (!data.session) throw new Error("No session returned");
|
|
110
|
+
return data.session;
|
|
111
|
+
},
|
|
112
|
+
catch: (e) => new SupabaseError({
|
|
113
|
+
cause: e,
|
|
114
|
+
message: "Invalid or expired code. Run lingo login to try again."
|
|
115
|
+
})
|
|
116
|
+
});
|
|
117
|
+
const refreshSession = (refreshToken) => Effect.tryPromise({
|
|
118
|
+
try: async () => {
|
|
119
|
+
const { data, error } = await supabase.auth.refreshSession({ refresh_token: refreshToken });
|
|
120
|
+
if (error) throw error;
|
|
121
|
+
if (!data.session) throw new Error("No session returned");
|
|
122
|
+
return data.session;
|
|
123
|
+
},
|
|
124
|
+
catch: (e) => new SupabaseError({
|
|
125
|
+
cause: e,
|
|
126
|
+
message: "Session expired"
|
|
127
|
+
})
|
|
128
|
+
});
|
|
129
|
+
var SupabaseError = class extends Data.TaggedError("SupabaseError") {};
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/services/auth.ts
|
|
132
|
+
var AuthError = class extends Data.TaggedError("AuthError") {};
|
|
133
|
+
var AuthContext = class extends Context.Tag("AuthContext")() {};
|
|
134
|
+
var AuthService = class extends Context.Tag("AuthService")() {};
|
|
135
|
+
function credentialsPaths(homeDir) {
|
|
136
|
+
const dir = path.join(homeDir ?? os.homedir(), ".lingo");
|
|
137
|
+
return {
|
|
138
|
+
dir,
|
|
139
|
+
file: path.join(dir, "credentials.json")
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const AuthServiceLive = Layer.effect(AuthService, pipe(Effect.context(), Effect.map((ctx) => {
|
|
143
|
+
const authCtx = Context.get(ctx, AuthContext);
|
|
144
|
+
const { dir: credsDir, file: credsFile } = credentialsPaths(authCtx.homeDir);
|
|
145
|
+
const readFile = () => {
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(fs.readFileSync(credsFile, "utf-8"));
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const writeFile = (creds) => {
|
|
153
|
+
fs.mkdirSync(credsDir, {
|
|
154
|
+
recursive: true,
|
|
155
|
+
mode: 448
|
|
156
|
+
});
|
|
157
|
+
fs.writeFileSync(credsFile, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
158
|
+
};
|
|
159
|
+
const deleteFile = () => {
|
|
160
|
+
try {
|
|
161
|
+
fs.unlinkSync(credsFile);
|
|
162
|
+
} catch {}
|
|
163
|
+
};
|
|
164
|
+
const resolve = Effect.gen(function* () {
|
|
165
|
+
if (authCtx.apiKeyFlag) return {
|
|
166
|
+
type: "api-key",
|
|
167
|
+
apiKey: authCtx.apiKeyFlag
|
|
168
|
+
};
|
|
169
|
+
const envKey = process.env.LINGO_API_KEY;
|
|
170
|
+
if (envKey) return {
|
|
171
|
+
type: "api-key",
|
|
172
|
+
apiKey: envKey
|
|
173
|
+
};
|
|
174
|
+
const stored = readFile();
|
|
175
|
+
if (!stored) return yield* Effect.fail(new AuthError({ message: "Not authenticated. Run lingo login to get started." }));
|
|
176
|
+
return stored;
|
|
177
|
+
});
|
|
178
|
+
const store = (creds) => Effect.try({
|
|
179
|
+
try: () => writeFile(creds),
|
|
180
|
+
catch: (e) => new AuthError({ message: `Failed to save credentials: ${e}` })
|
|
181
|
+
});
|
|
182
|
+
return {
|
|
183
|
+
resolve,
|
|
184
|
+
store,
|
|
185
|
+
clear: Effect.sync(() => deleteFile()),
|
|
186
|
+
ensureFresh: Effect.gen(function* () {
|
|
187
|
+
const creds = yield* resolve;
|
|
188
|
+
if (creds.type === "api-key") return creds;
|
|
189
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
190
|
+
if (creds.expiresAt > now + 60) return creds;
|
|
191
|
+
const session = yield* pipe(refreshSession(creds.refreshToken), Effect.mapError(() => new AuthError({ message: "Session expired. Run lingo login to get started." })));
|
|
192
|
+
const refreshed = {
|
|
193
|
+
type: "session",
|
|
194
|
+
email: session.user.email ?? creds.email,
|
|
195
|
+
accessToken: session.access_token,
|
|
196
|
+
refreshToken: session.refresh_token,
|
|
197
|
+
expiresAt: session.expires_at ?? now + session.expires_in
|
|
198
|
+
};
|
|
199
|
+
yield* store(refreshed);
|
|
200
|
+
return refreshed;
|
|
201
|
+
})
|
|
202
|
+
};
|
|
203
|
+
})));
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/services/api-client.ts
|
|
206
|
+
const BASE_URL = process.env.LINGO_API_URL ?? "https://api.lingo.dev";
|
|
207
|
+
var ApiError = class extends Data.TaggedError("ApiError") {};
|
|
208
|
+
var ApiClient = class extends Context.Tag("ApiClient")() {};
|
|
209
|
+
function authHeaders(creds) {
|
|
210
|
+
if (creds.type === "session") return { Authorization: `Bearer ${creds.accessToken}` };
|
|
211
|
+
return { "X-API-Key": creds.apiKey };
|
|
212
|
+
}
|
|
213
|
+
function request(method, path, creds, body) {
|
|
214
|
+
return Effect.tryPromise({
|
|
215
|
+
try: async () => {
|
|
216
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
217
|
+
method,
|
|
218
|
+
headers: {
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
...authHeaders(creds)
|
|
221
|
+
},
|
|
222
|
+
body: body ? JSON.stringify(body) : void 0
|
|
223
|
+
});
|
|
224
|
+
if (!res.ok) {
|
|
225
|
+
const text = await res.text().catch(() => "");
|
|
226
|
+
throw new Error(`${res.status}: ${text}`);
|
|
227
|
+
}
|
|
228
|
+
return await res.json();
|
|
229
|
+
},
|
|
230
|
+
catch: (e) => new ApiError({ message: e instanceof Error ? e.message : String(e) })
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const ApiClientLive = Layer.effect(ApiClient, Effect.map(AuthService, (auth) => {
|
|
234
|
+
const withAuth = (fn) => pipe(auth.ensureFresh, Effect.flatMap(fn));
|
|
235
|
+
return {
|
|
236
|
+
listMemberships: withAuth((creds) => request("GET", "/users/me/memberships", creds)),
|
|
237
|
+
createOrganization: (name) => withAuth((creds) => request("POST", "/organizations", creds, { name })),
|
|
238
|
+
listEngines: (orgId) => withAuth((creds) => request("GET", `/organizations/${orgId}/engines`, creds)),
|
|
239
|
+
createEngine: (orgId, name) => withAuth((creds) => request("POST", "/engines", creds, {
|
|
240
|
+
ownerOrganizationId: orgId,
|
|
241
|
+
name
|
|
242
|
+
})),
|
|
243
|
+
getMe: withAuth((creds) => request("GET", "/users/me", creds)),
|
|
244
|
+
updateMe: (payload) => withAuth((creds) => request("PATCH", "/users/me", creds, payload)),
|
|
245
|
+
localize: (params) => withAuth((creds) => request("POST", "/process/localize", creds, params))
|
|
246
|
+
};
|
|
247
|
+
}));
|
|
248
|
+
//#endregion
|
|
249
|
+
//#region src/services/config.ts
|
|
250
|
+
var ConfigError = class extends Data.TaggedError("ConfigError") {};
|
|
251
|
+
var ConfigService = class extends Context.Tag("ConfigService")() {};
|
|
252
|
+
const CONFIG_DIR = ".lingo";
|
|
253
|
+
const CONFIG_FILE = "config.json";
|
|
254
|
+
const HOME = os.homedir();
|
|
255
|
+
function findConfigPath(startDir) {
|
|
256
|
+
let dir = path.resolve(startDir);
|
|
257
|
+
while (true) {
|
|
258
|
+
const candidate = path.join(dir, CONFIG_DIR, CONFIG_FILE);
|
|
259
|
+
try {
|
|
260
|
+
fs.accessSync(candidate, fs.constants.R_OK);
|
|
261
|
+
return candidate;
|
|
262
|
+
} catch {}
|
|
263
|
+
const parent = path.dirname(dir);
|
|
264
|
+
if (parent === dir || dir === HOME) return null;
|
|
265
|
+
dir = parent;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function readConfigAt(filePath) {
|
|
269
|
+
try {
|
|
270
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
271
|
+
const parsed = JSON.parse(content);
|
|
272
|
+
if (parsed.orgId && parsed.engineId) return parsed;
|
|
273
|
+
return null;
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const ConfigServiceLive = Layer.succeed(ConfigService, {
|
|
279
|
+
resolve: Effect.sync(() => {
|
|
280
|
+
const configPath = findConfigPath(process.cwd());
|
|
281
|
+
if (!configPath) return null;
|
|
282
|
+
return readConfigAt(configPath);
|
|
283
|
+
}),
|
|
284
|
+
store: (config) => Effect.try({
|
|
285
|
+
try: () => {
|
|
286
|
+
const dir = path.join(process.cwd(), CONFIG_DIR);
|
|
287
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
288
|
+
fs.writeFileSync(path.join(dir, CONFIG_FILE), JSON.stringify(config, null, 2) + "\n");
|
|
289
|
+
},
|
|
290
|
+
catch: (e) => new ConfigError({ message: `Failed to save config: ${e}` })
|
|
291
|
+
}),
|
|
292
|
+
clear: Effect.sync(() => {
|
|
293
|
+
try {
|
|
294
|
+
fs.unlinkSync(path.join(process.cwd(), CONFIG_DIR, CONFIG_FILE));
|
|
295
|
+
} catch {}
|
|
296
|
+
}),
|
|
297
|
+
resolvedPath: Effect.sync(() => findConfigPath(process.cwd()))
|
|
298
|
+
});
|
|
299
|
+
//#endregion
|
|
300
|
+
//#region src/commands/localize.ts
|
|
301
|
+
const dryRun = Options.boolean("dry-run").pipe(Options.withDescription("Show what would change without writing"));
|
|
302
|
+
const localizeCommand = Command.make("localize", {
|
|
303
|
+
sourceLocale: sourceLocaleOption,
|
|
304
|
+
targetLocale: targetLocaleOption,
|
|
305
|
+
out: outOption,
|
|
306
|
+
dryRun
|
|
307
|
+
}, ({ sourceLocale, targetLocale: targetLocaleChunk, out, dryRun }) => Effect.gen(function* () {
|
|
308
|
+
const api = yield* ApiClient;
|
|
309
|
+
const config = yield* ConfigService;
|
|
310
|
+
const targetLocales = [...targetLocaleChunk];
|
|
311
|
+
if (targetLocales.length === 0) {
|
|
312
|
+
yield* Console.error("No target locales specified. Use --target-locale (e.g., --target-locale es --target-locale fr).");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const cwd = process.cwd();
|
|
316
|
+
const outDir = path.resolve(cwd, out);
|
|
317
|
+
const sourcePath = path.join(outDir, `${sourceLocale}.jsonc`);
|
|
318
|
+
const sourceContent = yield* Effect.tryPromise(() => tryReadFile(sourcePath));
|
|
319
|
+
if (!sourceContent) {
|
|
320
|
+
yield* Console.error(`No source locale file at ${out}/${sourceLocale}.jsonc. Run \`lingo extract\` first.`);
|
|
321
|
+
return yield* Effect.die(/* @__PURE__ */ new Error(`Missing ${out}/${sourceLocale}.jsonc`));
|
|
322
|
+
}
|
|
323
|
+
const sourceFile = readLocaleFile(sourceContent);
|
|
324
|
+
const projectConfig = yield* config.resolve;
|
|
325
|
+
let totalNew = 0;
|
|
326
|
+
const failedLocales = [];
|
|
327
|
+
for (const locale of targetLocales) {
|
|
328
|
+
const targetPath = path.join(outDir, `${locale}.jsonc`);
|
|
329
|
+
const targetContent = yield* Effect.tryPromise(() => tryReadFile(targetPath));
|
|
330
|
+
const targetFile = targetContent ? readLocaleFile(targetContent) : { entries: [] };
|
|
331
|
+
const plan = planLocalization(sourceFile, targetFile, locale);
|
|
332
|
+
if (plan.missing.length === 0) {
|
|
333
|
+
yield* Console.log(` ${locale}: up to date (${plan.existing} strings)`);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (dryRun) {
|
|
337
|
+
yield* Console.log(` ${locale}: ${plan.missing.length} strings to localize (dry run)`);
|
|
338
|
+
totalNew += plan.missing.length;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const { data, hints } = toApiPayload(plan.missing);
|
|
342
|
+
const result = yield* pipe(api.localize({
|
|
343
|
+
sourceLocale,
|
|
344
|
+
targetLocale: locale,
|
|
345
|
+
data,
|
|
346
|
+
hints,
|
|
347
|
+
engineId: projectConfig?.engineId,
|
|
348
|
+
triggerType: "cli"
|
|
349
|
+
}), Effect.catchAll((e) => Effect.gen(function* () {
|
|
350
|
+
yield* Console.error(` ${locale}: failed - ${e.message}`);
|
|
351
|
+
failedLocales.push(locale);
|
|
352
|
+
return null;
|
|
353
|
+
})));
|
|
354
|
+
if (!result) continue;
|
|
355
|
+
const translated = applyTranslations(plan.missing, result.data);
|
|
356
|
+
const merged = mergeEntries(targetFile, [...getActiveEntries(targetFile.entries), ...translated]);
|
|
357
|
+
yield* Effect.tryPromise(() => fs$1.mkdir(outDir, { recursive: true }));
|
|
358
|
+
yield* Effect.tryPromise(() => fs$1.writeFile(targetPath, writeLocaleFile(merged)));
|
|
359
|
+
totalNew += plan.missing.length;
|
|
360
|
+
yield* Console.log(` ${locale}: ${plan.missing.length} localized (${plan.existing + plan.missing.length} total)`);
|
|
361
|
+
}
|
|
362
|
+
if (dryRun) yield* Console.log(`\nDry run: ${totalNew} strings would be localized across ${targetLocales.length} locale(s).`);
|
|
363
|
+
else if (totalNew > 0 || failedLocales.length > 0) {
|
|
364
|
+
yield* Console.log(`\nLocalized ${totalNew} strings across ${targetLocales.length} locale(s).`);
|
|
365
|
+
if (failedLocales.length > 0) yield* Console.error(`Failed: ${failedLocales.join(", ")}`);
|
|
366
|
+
}
|
|
367
|
+
})).pipe(Command.withDescription("Localize missing strings to target locales"));
|
|
368
|
+
//#endregion
|
|
369
|
+
//#region src/commands/ship.ts
|
|
370
|
+
const shipCommand = Command.make("ship", {
|
|
371
|
+
sourceLocale: sourceLocaleOption,
|
|
372
|
+
targetLocale: targetLocaleOption,
|
|
373
|
+
src: srcOption,
|
|
374
|
+
out: outOption,
|
|
375
|
+
json: jsonOption
|
|
376
|
+
}, ({ sourceLocale, targetLocale: targetLocaleChunk, src, out, json }) => Effect.gen(function* () {
|
|
377
|
+
const api = yield* ApiClient;
|
|
378
|
+
const projectConfig = yield* (yield* ConfigService).resolve;
|
|
379
|
+
const targetLocales = [...targetLocaleChunk];
|
|
380
|
+
const cwd = process.cwd();
|
|
381
|
+
const srcDir = path.resolve(cwd, src);
|
|
382
|
+
const outDir = path.resolve(cwd, out);
|
|
383
|
+
const localePath = path.join(outDir, `${sourceLocale}.jsonc`);
|
|
384
|
+
const typesPath = path.resolve(cwd, "lingo.d.ts");
|
|
385
|
+
if (!json) yield* Console.log("Extracting...");
|
|
386
|
+
const filePaths = yield* Effect.tryPromise(() => findSourceFiles(srcDir));
|
|
387
|
+
if (filePaths.length === 0) {
|
|
388
|
+
yield* Console.error(`No .ts/.tsx files found in ${src}/`);
|
|
389
|
+
return yield* Effect.die(/* @__PURE__ */ new Error(`No source files in ${src}/`));
|
|
390
|
+
}
|
|
391
|
+
const { messages, warnings, collisions } = runExtractionPipeline(yield* Effect.tryPromise(() => Promise.all(filePaths.map(async (f) => ({
|
|
392
|
+
code: await fs$1.readFile(f, "utf-8"),
|
|
393
|
+
filePath: path.relative(cwd, f)
|
|
394
|
+
})))));
|
|
395
|
+
if (!json) for (const w of warnings) yield* Console.log(` warn: ${w.src} - ${w.message}`);
|
|
396
|
+
if (collisions.length > 0) {
|
|
397
|
+
for (const c of collisions) {
|
|
398
|
+
yield* Console.error(` collision: key "${c.key}" ->`);
|
|
399
|
+
for (const e of c.entries) yield* Console.error(` "${e.source}" at ${e.src}`);
|
|
400
|
+
}
|
|
401
|
+
return yield* Effect.die(/* @__PURE__ */ new Error(`${collisions.length} hash collision(s) detected.`));
|
|
402
|
+
}
|
|
403
|
+
const entries = toLocaleEntries(messages);
|
|
404
|
+
const existingContent = yield* Effect.tryPromise(() => tryReadFile(localePath));
|
|
405
|
+
const existing = existingContent ? readLocaleFile(existingContent) : { entries: [] };
|
|
406
|
+
const merged = mergeEntries(existing, entries);
|
|
407
|
+
yield* Effect.tryPromise(() => fs$1.mkdir(outDir, { recursive: true }));
|
|
408
|
+
yield* Effect.tryPromise(() => fs$1.writeFile(localePath, writeLocaleFile(merged)));
|
|
409
|
+
yield* Effect.tryPromise(() => fs$1.writeFile(typesPath, generateTypes(merged)));
|
|
410
|
+
if (!json) {
|
|
411
|
+
const newCount = entries.filter((e) => !existing.entries.some((ex) => ex.key === e.key)).length;
|
|
412
|
+
yield* Console.log(` ${messages.length} strings extracted (${newCount} new)`);
|
|
413
|
+
}
|
|
414
|
+
let totalLocalized = 0;
|
|
415
|
+
const failedLocales = [];
|
|
416
|
+
if (targetLocales.length > 0) {
|
|
417
|
+
if (!json) yield* Console.log("Localizing...");
|
|
418
|
+
const sourceFile = readLocaleFile(yield* Effect.tryPromise(() => fs$1.readFile(localePath, "utf-8")));
|
|
419
|
+
for (const locale of targetLocales) {
|
|
420
|
+
const targetPath = path.join(outDir, `${locale}.jsonc`);
|
|
421
|
+
const targetContent = yield* Effect.tryPromise(() => tryReadFile(targetPath));
|
|
422
|
+
const targetFile = targetContent ? readLocaleFile(targetContent) : { entries: [] };
|
|
423
|
+
const plan = planLocalization(sourceFile, targetFile, locale);
|
|
424
|
+
if (plan.missing.length === 0) {
|
|
425
|
+
if (!json) yield* Console.log(` ${locale}: up to date`);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const { data, hints } = toApiPayload(plan.missing);
|
|
429
|
+
const result = yield* pipe(api.localize({
|
|
430
|
+
sourceLocale,
|
|
431
|
+
targetLocale: locale,
|
|
432
|
+
data,
|
|
433
|
+
hints,
|
|
434
|
+
engineId: projectConfig?.engineId,
|
|
435
|
+
triggerType: "cli"
|
|
436
|
+
}), Effect.catchAll((e) => Effect.gen(function* () {
|
|
437
|
+
yield* Console.error(` ${locale}: failed - ${e.message}`);
|
|
438
|
+
failedLocales.push(locale);
|
|
439
|
+
return null;
|
|
440
|
+
})));
|
|
441
|
+
if (!result) continue;
|
|
442
|
+
const translated = applyTranslations(plan.missing, result.data);
|
|
443
|
+
const mergedTarget = mergeEntries(targetFile, [...getActiveEntries(targetFile.entries), ...translated]);
|
|
444
|
+
yield* Effect.tryPromise(() => fs$1.writeFile(targetPath, writeLocaleFile(mergedTarget)));
|
|
445
|
+
totalLocalized += plan.missing.length;
|
|
446
|
+
if (!json) yield* Console.log(` ${locale}: ${plan.missing.length} strings localized`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const sourceFile = readLocaleFile(yield* Effect.tryPromise(() => fs$1.readFile(localePath, "utf-8")));
|
|
450
|
+
const statuses = [computeSourceStatus(sourceFile, sourceLocale)];
|
|
451
|
+
for (const locale of targetLocales) {
|
|
452
|
+
const content = yield* Effect.tryPromise(() => tryReadFile(path.join(outDir, `${locale}.jsonc`)));
|
|
453
|
+
const targetFile = content ? readLocaleFile(content) : { entries: [] };
|
|
454
|
+
statuses.push(computeLocaleStatus(sourceFile, targetFile, locale));
|
|
455
|
+
}
|
|
456
|
+
if (json) yield* Console.log(JSON.stringify({
|
|
457
|
+
extracted: messages.length,
|
|
458
|
+
localized: totalLocalized,
|
|
459
|
+
...failedLocales.length > 0 ? { failed: failedLocales } : {},
|
|
460
|
+
statuses
|
|
461
|
+
}, null, 2));
|
|
462
|
+
else {
|
|
463
|
+
yield* Console.log("");
|
|
464
|
+
yield* Console.log(formatStatusTable(statuses));
|
|
465
|
+
if (statuses.reduce((sum, s) => sum + s.missing, 0) === 0 && totalLocalized === 0) yield* Console.log("\nAll locales up to date. Nothing to do.");
|
|
466
|
+
}
|
|
467
|
+
})).pipe(Command.withDescription("Extract, localize, and report status (idempotent pipeline)"));
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region src/commands/status.ts
|
|
470
|
+
const statusCommand = Command.make("status", {
|
|
471
|
+
json: jsonOption,
|
|
472
|
+
sourceLocale: sourceLocaleOption,
|
|
473
|
+
out: outOption
|
|
474
|
+
}, ({ json, sourceLocale, out }) => Effect.gen(function* () {
|
|
475
|
+
const cwd = process.cwd();
|
|
476
|
+
const outDir = path.resolve(cwd, out);
|
|
477
|
+
const sourcePath = path.join(outDir, `${sourceLocale}.jsonc`);
|
|
478
|
+
const sourceContent = yield* Effect.tryPromise(() => tryReadFile(sourcePath));
|
|
479
|
+
if (!sourceContent) {
|
|
480
|
+
yield* Console.error(`No source locale file at ${out}/${sourceLocale}.jsonc. Run \`lingo extract\` first.`);
|
|
481
|
+
return yield* Effect.die(/* @__PURE__ */ new Error(`Missing ${out}/${sourceLocale}.jsonc`));
|
|
482
|
+
}
|
|
483
|
+
const sourceFile = readLocaleFile(sourceContent);
|
|
484
|
+
const targetLocales = yield* Effect.tryPromise(() => discoverLocales(outDir, sourceLocale));
|
|
485
|
+
const statuses = [computeSourceStatus(sourceFile, sourceLocale)];
|
|
486
|
+
for (const locale of targetLocales) {
|
|
487
|
+
const content = yield* Effect.tryPromise(() => tryReadFile(path.join(outDir, `${locale}.jsonc`)));
|
|
488
|
+
const targetFile = content ? readLocaleFile(content) : { entries: [] };
|
|
489
|
+
statuses.push(computeLocaleStatus(sourceFile, targetFile, locale));
|
|
490
|
+
}
|
|
491
|
+
if (json) yield* Console.log(JSON.stringify(statuses, null, 2));
|
|
492
|
+
else yield* Console.log(formatStatusTable(statuses));
|
|
493
|
+
const totalMissing = statuses.reduce((sum, s) => sum + s.missing, 0);
|
|
494
|
+
if (totalMissing > 0) return yield* Effect.die(/* @__PURE__ */ new Error(`${totalMissing} missing translation(s) across ${statuses.length} locale(s)`));
|
|
495
|
+
})).pipe(Command.withDescription("Show per-locale translation completeness"));
|
|
496
|
+
//#endregion
|
|
497
|
+
//#region src/commands/check.ts
|
|
498
|
+
const checkCommand = Command.make("check", {
|
|
499
|
+
json: jsonOption,
|
|
500
|
+
src: srcOption,
|
|
501
|
+
sourceLocale: sourceLocaleOption,
|
|
502
|
+
out: outOption
|
|
503
|
+
}, ({ json, src, sourceLocale, out }) => Effect.gen(function* () {
|
|
504
|
+
const cwd = process.cwd();
|
|
505
|
+
const srcDir = path.resolve(cwd, src);
|
|
506
|
+
const outDir = path.resolve(cwd, out);
|
|
507
|
+
const localePath = path.join(outDir, `${sourceLocale}.jsonc`);
|
|
508
|
+
const typesPath = path.resolve(cwd, "lingo.d.ts");
|
|
509
|
+
const filePaths = yield* Effect.tryPromise(() => findSourceFiles(srcDir));
|
|
510
|
+
const { messages, warnings } = runExtractionPipeline(yield* Effect.tryPromise(() => Promise.all(filePaths.map(async (f) => ({
|
|
511
|
+
code: await fs$1.readFile(f, "utf-8"),
|
|
512
|
+
filePath: path.relative(cwd, f)
|
|
513
|
+
})))));
|
|
514
|
+
const entries = toLocaleEntries(messages);
|
|
515
|
+
const existingJsoncContent = (yield* Effect.tryPromise(() => tryReadFile(localePath))) ?? "";
|
|
516
|
+
const merged = mergeEntries(existingJsoncContent ? readLocaleFile(existingJsoncContent) : { entries: [] }, entries);
|
|
517
|
+
const generatedJsonc = writeLocaleFile(merged);
|
|
518
|
+
const generatedTypes = generateTypes(merged);
|
|
519
|
+
const existingTypes = (yield* Effect.tryPromise(() => tryReadFile(typesPath))) ?? "";
|
|
520
|
+
const targetLocales = yield* Effect.tryPromise(() => discoverLocales(outDir, sourceLocale).catch(() => []));
|
|
521
|
+
const targetFiles = yield* Effect.tryPromise(() => Promise.all(targetLocales.map(async (locale) => {
|
|
522
|
+
const content = await tryReadFile(path.join(outDir, `${locale}.jsonc`));
|
|
523
|
+
return {
|
|
524
|
+
locale,
|
|
525
|
+
file: content ? readLocaleFile(content) : { entries: [] }
|
|
526
|
+
};
|
|
527
|
+
})));
|
|
528
|
+
const result = runChecks({
|
|
529
|
+
warnings,
|
|
530
|
+
messageCount: messages.length,
|
|
531
|
+
generatedJsonc,
|
|
532
|
+
existingJsonc: existingJsoncContent,
|
|
533
|
+
generatedTypes,
|
|
534
|
+
existingTypes,
|
|
535
|
+
sourceFile: merged,
|
|
536
|
+
targetFiles
|
|
537
|
+
});
|
|
538
|
+
if (json) yield* Console.log(JSON.stringify(result, null, 2));
|
|
539
|
+
else yield* Console.log(formatCheckResult(result));
|
|
540
|
+
if (result.issues.length > 0) return yield* Effect.die(/* @__PURE__ */ new Error(`${result.issues.length} issue(s) found`));
|
|
541
|
+
})).pipe(Command.withDescription("Verify i18n consistency: context, translations, types"));
|
|
542
|
+
//#endregion
|
|
543
|
+
//#region src/commands/guide.ts
|
|
544
|
+
const allowedGuides = new Set([
|
|
545
|
+
"index",
|
|
546
|
+
"setup",
|
|
547
|
+
"api",
|
|
548
|
+
"migrate"
|
|
549
|
+
]);
|
|
550
|
+
const guideArg = pipe(Args.text({ name: "guide" }), Args.withDescription("Guide name"), Args.withDefault("index"));
|
|
551
|
+
async function resolveGuidesDir() {
|
|
552
|
+
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
553
|
+
const candidates = [path.resolve(here, "..", "..", "guides"), path.resolve(here, "..", "guides")];
|
|
554
|
+
for (const candidate of candidates) try {
|
|
555
|
+
if ((await fs$1.stat(candidate)).isDirectory()) return candidate;
|
|
556
|
+
} catch {}
|
|
557
|
+
throw new Error("guides directory not found in package");
|
|
558
|
+
}
|
|
559
|
+
async function readGuide(name) {
|
|
560
|
+
const guidesDir = await resolveGuidesDir();
|
|
561
|
+
const file = path.join(guidesDir, `${name}.md`);
|
|
562
|
+
return fs$1.readFile(file, "utf8");
|
|
563
|
+
}
|
|
564
|
+
const guideCommand = Command.make("guide", { guide: guideArg }, ({ guide }) => Effect.gen(function* () {
|
|
565
|
+
const name = guide.toLowerCase();
|
|
566
|
+
if (!allowedGuides.has(name)) {
|
|
567
|
+
yield* Console.log("Unknown guide. Use one of: index, setup, api, migrate.");
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const content = yield* Effect.tryPromise(() => readGuide(name));
|
|
571
|
+
yield* Console.log(content);
|
|
572
|
+
})).pipe(Command.withDescription("Print Lingo guides (setup, api, migrate)."));
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region src/commands/install.ts
|
|
575
|
+
/**
|
|
576
|
+
* `lingo install` - One command to set up the @lingo.dev/react DX stack.
|
|
577
|
+
* Detects AI tools, installs packages, copies the /lingo skill.
|
|
578
|
+
* Non-destructive and idempotent.
|
|
579
|
+
*/
|
|
580
|
+
async function exists(p) {
|
|
581
|
+
try {
|
|
582
|
+
await fs$1.access(p);
|
|
583
|
+
return true;
|
|
584
|
+
} catch {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/** Walk up from cwd looking for a file/dir. Returns the directory containing it, or null. */
|
|
589
|
+
async function findUp(name, from) {
|
|
590
|
+
let dir = from;
|
|
591
|
+
while (true) {
|
|
592
|
+
if (await exists(path.join(dir, name))) return dir;
|
|
593
|
+
const parent = path.dirname(dir);
|
|
594
|
+
if (parent === dir) return null;
|
|
595
|
+
dir = parent;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
async function detectProject(cwd) {
|
|
599
|
+
const lockfiles = [
|
|
600
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
601
|
+
["bun.lockb", "bun"],
|
|
602
|
+
["bun.lock", "bun"],
|
|
603
|
+
["yarn.lock", "yarn"],
|
|
604
|
+
["package-lock.json", "npm"]
|
|
605
|
+
];
|
|
606
|
+
let pm = "npm";
|
|
607
|
+
for (const [file, manager] of lockfiles) if (await findUp(file, cwd)) {
|
|
608
|
+
pm = manager;
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
let framework = "unknown";
|
|
612
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
613
|
+
if (await exists(pkgPath)) {
|
|
614
|
+
const pkg = JSON.parse(await fs$1.readFile(pkgPath, "utf8"));
|
|
615
|
+
const deps = {
|
|
616
|
+
...pkg.dependencies,
|
|
617
|
+
...pkg.devDependencies
|
|
618
|
+
};
|
|
619
|
+
if (deps.next) {
|
|
620
|
+
const hasPages = await exists(path.join(cwd, "pages")) || await exists(path.join(cwd, "src/pages"));
|
|
621
|
+
const hasApp = await exists(path.join(cwd, "app")) || await exists(path.join(cwd, "src/app"));
|
|
622
|
+
framework = hasPages ? "next-pages" : hasApp ? "next-app" : "next-pages";
|
|
623
|
+
} else if (deps.vite) framework = "vite";
|
|
624
|
+
else if (deps.react) framework = "react";
|
|
625
|
+
}
|
|
626
|
+
const aiToolDirs = [
|
|
627
|
+
[".claude", "claude-code"],
|
|
628
|
+
[".cursor", "cursor"],
|
|
629
|
+
[".windsurf", "windsurf"],
|
|
630
|
+
[".vscode", "vscode"]
|
|
631
|
+
];
|
|
632
|
+
const aiTools = [];
|
|
633
|
+
for (const [dir, tool] of aiToolDirs) if (await findUp(dir, cwd)) aiTools.push(tool);
|
|
634
|
+
return {
|
|
635
|
+
framework,
|
|
636
|
+
pm,
|
|
637
|
+
aiTools
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
function installCmd(pm, packages, dev) {
|
|
641
|
+
return [
|
|
642
|
+
pm,
|
|
643
|
+
pm === "yarn" ? "add" : pm === "npm" ? "install" : "add",
|
|
644
|
+
dev ? pm === "npm" ? "--save-dev" : "-D" : "",
|
|
645
|
+
...packages
|
|
646
|
+
].filter(Boolean).join(" ");
|
|
647
|
+
}
|
|
648
|
+
async function installPackages(cwd, pm, framework) {
|
|
649
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
650
|
+
const pkg = JSON.parse(await fs$1.readFile(pkgPath, "utf8"));
|
|
651
|
+
const allDeps = {
|
|
652
|
+
...pkg.dependencies,
|
|
653
|
+
...pkg.devDependencies
|
|
654
|
+
};
|
|
655
|
+
const toInstall = [];
|
|
656
|
+
if (!allDeps["@lingo.dev/react"]) toInstall.push("@lingo.dev/react");
|
|
657
|
+
if (framework.startsWith("next") && !allDeps["@lingo.dev/react-next"]) toInstall.push("@lingo.dev/react-next");
|
|
658
|
+
const toInstallDev = [];
|
|
659
|
+
if (!allDeps["@lingo.dev/cli"]) toInstallDev.push("@lingo.dev/cli");
|
|
660
|
+
const commands = [];
|
|
661
|
+
if (toInstall.length > 0) commands.push(installCmd(pm, toInstall, false));
|
|
662
|
+
if (toInstallDev.length > 0) commands.push(installCmd(pm, toInstallDev, true));
|
|
663
|
+
return commands;
|
|
664
|
+
}
|
|
665
|
+
const AI_TOOL_DIR = {
|
|
666
|
+
"claude-code": ".claude",
|
|
667
|
+
cursor: ".cursor",
|
|
668
|
+
windsurf: ".windsurf",
|
|
669
|
+
vscode: ".vscode"
|
|
670
|
+
};
|
|
671
|
+
const SKILL_NAMES = ["lingo"];
|
|
672
|
+
async function copySkills(cwd, aiTools, skillsSourceDir) {
|
|
673
|
+
const copied = [];
|
|
674
|
+
for (const tool of aiTools) {
|
|
675
|
+
const root = await findUp(AI_TOOL_DIR[tool], cwd);
|
|
676
|
+
if (!root) continue;
|
|
677
|
+
const targetDir = path.join(root, AI_TOOL_DIR[tool], "skills");
|
|
678
|
+
for (const name of SKILL_NAMES) {
|
|
679
|
+
const src = path.join(skillsSourceDir, name, "SKILL.md");
|
|
680
|
+
const dest = path.join(targetDir, name, "SKILL.md");
|
|
681
|
+
if (await exists(dest)) continue;
|
|
682
|
+
await fs$1.mkdir(path.dirname(dest), { recursive: true });
|
|
683
|
+
await fs$1.copyFile(src, dest);
|
|
684
|
+
copied.push(`${tool}: ${name}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return copied;
|
|
688
|
+
}
|
|
689
|
+
const installCommand = Command.make("install", {}, () => Effect.gen(function* () {
|
|
690
|
+
const cwd = process.cwd();
|
|
691
|
+
const project = yield* Effect.tryPromise(() => detectProject(cwd));
|
|
692
|
+
yield* Console.log(` Detected: ${{
|
|
693
|
+
"next-pages": "Next.js Pages Router",
|
|
694
|
+
"next-app": "Next.js App Router",
|
|
695
|
+
vite: "Vite + React",
|
|
696
|
+
react: "React",
|
|
697
|
+
unknown: "React (unknown framework)"
|
|
698
|
+
}[project.framework]}`);
|
|
699
|
+
yield* Console.log(` Detected: ${project.pm} (package manager)`);
|
|
700
|
+
if (project.aiTools.length > 0) yield* Console.log(` Detected: ${project.aiTools.join(", ")} (AI tools)`);
|
|
701
|
+
yield* Console.log("");
|
|
702
|
+
const commands = yield* Effect.tryPromise(() => installPackages(cwd, project.pm, project.framework));
|
|
703
|
+
if (commands.length > 0) {
|
|
704
|
+
for (const cmd of commands) yield* Console.log(` Run: ${cmd}`);
|
|
705
|
+
yield* Console.log("");
|
|
706
|
+
} else yield* Console.log(" Packages already installed.");
|
|
707
|
+
if (project.aiTools.length > 0) {
|
|
708
|
+
const thisDir = path.dirname(new URL(import.meta.url).pathname);
|
|
709
|
+
const candidates = [path.resolve(thisDir, "..", "..", "skills"), path.resolve(thisDir, "..", "skills")];
|
|
710
|
+
const skillsDir = yield* Effect.tryPromise(async () => {
|
|
711
|
+
for (const c of candidates) if (await exists(c)) return c;
|
|
712
|
+
return "";
|
|
713
|
+
});
|
|
714
|
+
if (yield* Effect.tryPromise(() => exists(skillsDir))) if ((yield* Effect.tryPromise(() => copySkills(cwd, project.aiTools, skillsDir))).length > 0) yield* Console.log(` Installed /lingo skill for: ${project.aiTools.join(", ")}`);
|
|
715
|
+
else yield* Console.log(" Skills already installed.");
|
|
716
|
+
else yield* Console.log(" Skills directory not found (running from source). Skipping skill copy.");
|
|
717
|
+
}
|
|
718
|
+
yield* Console.log("");
|
|
719
|
+
yield* Console.log(" Run /lingo in your AI assistant, or \"npx @lingo.dev/cli guide\" for instructions.");
|
|
720
|
+
})).pipe(Command.withDescription("Set up @lingo.dev/react DX stack: packages, skills"));
|
|
721
|
+
//#endregion
|
|
722
|
+
//#region src/constants.ts
|
|
723
|
+
const TAGLINE = "Lingo.dev — The Localization Engineering Platform";
|
|
724
|
+
//#endregion
|
|
725
|
+
//#region src/services/banner.ts
|
|
726
|
+
const SCRAMBLE_GLYPHS = "░▒▓█▄▀╔╗╚╝║═╬╦╩╠╣┃━";
|
|
727
|
+
const FRAME_MS = 20;
|
|
728
|
+
const REVEAL_MS = 1200;
|
|
729
|
+
const SETTLE_MS = 300;
|
|
730
|
+
const COLUMN_JITTER_MS = 120;
|
|
731
|
+
const GLITCH_ZONE_MS = 80;
|
|
732
|
+
const ANSI_RE = /\x1B\[[0-9;]*m/g;
|
|
733
|
+
const stripAnsi$1 = (s) => s.replace(ANSI_RE, "");
|
|
734
|
+
function randomGlyph() {
|
|
735
|
+
return SCRAMBLE_GLYPHS[Math.floor(Math.random() * 19)];
|
|
736
|
+
}
|
|
737
|
+
function shouldSkip() {
|
|
738
|
+
return !!(process.env.NO_COLOR || process.env.CI || !process.stdout.isTTY);
|
|
739
|
+
}
|
|
740
|
+
function renderArt() {
|
|
741
|
+
const result = cfonts.render("Lingo.dev", {
|
|
742
|
+
font: "block",
|
|
743
|
+
space: false
|
|
744
|
+
});
|
|
745
|
+
if (!result) return [];
|
|
746
|
+
return stripAnsi$1(result.string).split("\n").filter((line) => line.trim().length > 0);
|
|
747
|
+
}
|
|
748
|
+
function computeRevealTimes(width) {
|
|
749
|
+
return Array.from({ length: width }, (_, col) => {
|
|
750
|
+
const base = col / width * REVEAL_MS;
|
|
751
|
+
const jitter = (Math.random() - .5) * COLUMN_JITTER_MS;
|
|
752
|
+
return Math.max(0, base + jitter);
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
function buildFrame(lines, elapsed, revealTimes) {
|
|
756
|
+
return lines.map((line) => Array.from(line).map((char, col) => {
|
|
757
|
+
if (char === " ") return " ";
|
|
758
|
+
const revealAt = revealTimes[col] ?? 0;
|
|
759
|
+
if (elapsed >= revealAt) return char;
|
|
760
|
+
if (revealAt - elapsed < GLITCH_ZONE_MS && Math.random() > .5) return char;
|
|
761
|
+
return randomGlyph();
|
|
762
|
+
}).join("")).join("\n");
|
|
763
|
+
}
|
|
764
|
+
const CLEAR_SCREEN = "\x1B[2J\x1B[H";
|
|
765
|
+
function animateBanner(signal) {
|
|
766
|
+
if (shouldSkip()) return Promise.resolve();
|
|
767
|
+
const lines = renderArt();
|
|
768
|
+
if (lines.length === 0) return Promise.resolve();
|
|
769
|
+
const revealTimes = computeRevealTimes(Math.max(...lines.map((l) => l.length)));
|
|
770
|
+
const totalMs = REVEAL_MS + SETTLE_MS;
|
|
771
|
+
process.stdout.write(CLEAR_SCREEN);
|
|
772
|
+
return new Promise((resolve) => {
|
|
773
|
+
const start = Date.now();
|
|
774
|
+
const tick = setInterval(() => {
|
|
775
|
+
if (signal?.aborted) {
|
|
776
|
+
clearInterval(tick);
|
|
777
|
+
logUpdate.clear();
|
|
778
|
+
resolve();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const elapsed = Date.now() - start;
|
|
782
|
+
logUpdate("\n" + buildFrame(lines, elapsed, revealTimes));
|
|
783
|
+
if (elapsed >= totalMs) {
|
|
784
|
+
clearInterval(tick);
|
|
785
|
+
logUpdate("\n" + lines.join("\n"));
|
|
786
|
+
logUpdate.done();
|
|
787
|
+
resolve();
|
|
788
|
+
}
|
|
789
|
+
}, FRAME_MS);
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
//#endregion
|
|
793
|
+
//#region src/services/prompt.ts
|
|
794
|
+
var PromptCancelledError = class extends Data.TaggedError("PromptCancelledError") {};
|
|
795
|
+
function unwrapCancel(value) {
|
|
796
|
+
if (clack.isCancel(value)) return Effect.fail(new PromptCancelledError({ message: "Cancelled." }));
|
|
797
|
+
return Effect.succeed(value);
|
|
798
|
+
}
|
|
799
|
+
const text = (opts) => Effect.flatMap(Effect.tryPromise(() => clack.text(opts)), unwrapCancel);
|
|
800
|
+
const select = (opts) => Effect.flatMap(Effect.tryPromise(() => clack.select(opts)), unwrapCancel);
|
|
801
|
+
const multiselect = (opts) => Effect.flatMap(Effect.tryPromise(() => clack.multiselect(opts)), unwrapCancel);
|
|
802
|
+
const spinner = () => clack.spinner();
|
|
803
|
+
const cancel = (message) => Effect.sync(() => clack.cancel(message));
|
|
804
|
+
const log = {
|
|
805
|
+
info: (message) => Effect.sync(() => clack.log.info(message)),
|
|
806
|
+
success: (message) => Effect.sync(() => clack.log.success(message)),
|
|
807
|
+
warn: (message) => Effect.sync(() => clack.log.warn(message)),
|
|
808
|
+
error: (message) => Effect.sync(() => clack.log.error(message)),
|
|
809
|
+
step: (message) => Effect.sync(() => clack.log.step(message)),
|
|
810
|
+
message: (message) => Effect.sync(() => clack.log.message(message))
|
|
811
|
+
};
|
|
812
|
+
//#endregion
|
|
813
|
+
//#region src/services/telemetry.ts
|
|
814
|
+
var TelemetryService = class extends Context.Tag("TelemetryService")() {};
|
|
815
|
+
const noop = {
|
|
816
|
+
capture: () => Effect.void,
|
|
817
|
+
identify: () => Effect.void,
|
|
818
|
+
shutdown: Effect.void
|
|
819
|
+
};
|
|
820
|
+
function resolveRepoId() {
|
|
821
|
+
return process.env.GITHUB_REPOSITORY_ID ?? process.env.CI_PROJECT_ID ?? process.env.BITBUCKET_REPO_UUID ?? hashOf(process.env.GITHUB_REPOSITORY ?? process.env.CI_PROJECT_PATH ?? process.env.CIRCLE_PROJECT_REPONAME ?? process.cwd());
|
|
822
|
+
}
|
|
823
|
+
function detectCI() {
|
|
824
|
+
if (!ci.isCI) return {
|
|
825
|
+
isCI: false,
|
|
826
|
+
provider: null,
|
|
827
|
+
isPR: null,
|
|
828
|
+
repoId: null
|
|
829
|
+
};
|
|
830
|
+
return {
|
|
831
|
+
isCI: true,
|
|
832
|
+
provider: ci.id,
|
|
833
|
+
isPR: ci.isPR,
|
|
834
|
+
repoId: resolveRepoId()
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
function getAnonymousId() {
|
|
838
|
+
const dir = path.join(os.homedir(), ".lingo");
|
|
839
|
+
const file = path.join(dir, "anonymous-id");
|
|
840
|
+
try {
|
|
841
|
+
return fs.readFileSync(file, "utf-8").trim();
|
|
842
|
+
} catch {
|
|
843
|
+
const id = crypto.randomUUID();
|
|
844
|
+
try {
|
|
845
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
846
|
+
fs.writeFileSync(file, id);
|
|
847
|
+
} catch {}
|
|
848
|
+
return id;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
function resolveDistinctId(ci, orgId, engineId) {
|
|
852
|
+
if (!ci.isCI) return getAnonymousId();
|
|
853
|
+
return `ci:${[
|
|
854
|
+
ci.provider ?? "ci",
|
|
855
|
+
ci.repoId,
|
|
856
|
+
orgId,
|
|
857
|
+
engineId
|
|
858
|
+
].filter(Boolean).join(":")}`;
|
|
859
|
+
}
|
|
860
|
+
function isDisabled() {
|
|
861
|
+
if (process.env.LINGO_TELEMETRY_DISABLED === "1") return true;
|
|
862
|
+
if (process.env.DO_NOT_TRACK === "1") return true;
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
function hashOf(input) {
|
|
866
|
+
return crypto.createHash("sha256").update(input).digest("hex").slice(0, 12);
|
|
867
|
+
}
|
|
868
|
+
const POSTHOG_KEY = "phc_eR0iSoQufBxNY36k0f0T15UvHJdTfHlh8rJcxsfhfXk";
|
|
869
|
+
const POSTHOG_HOST = "https://eu.i.posthog.com";
|
|
870
|
+
const FLUSH_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), "flush-telemetry.js");
|
|
871
|
+
const TelemetryServiceLive = Layer.effect(TelemetryService, Effect.gen(function* () {
|
|
872
|
+
if (isDisabled()) return noop;
|
|
873
|
+
const projectConfig = yield* pipe((yield* ConfigService).resolve, Effect.catchAll(() => Effect.succeed(null)));
|
|
874
|
+
const ciCtx = detectCI();
|
|
875
|
+
const distinctId = resolveDistinctId(ciCtx, projectConfig?.orgId ?? null, projectConfig?.engineId ?? null);
|
|
876
|
+
const creds = yield* pipe((yield* AuthService).resolve, Effect.catchAll(() => Effect.succeed(null)));
|
|
877
|
+
let identifyTraits;
|
|
878
|
+
if (creds?.type === "session") identifyTraits = { email: creds.email };
|
|
879
|
+
else if (creds?.type === "api-key") {
|
|
880
|
+
const me = yield* pipe((yield* ApiClient).getMe, Effect.catchAll(() => Effect.succeed(null)));
|
|
881
|
+
if (me) identifyTraits = {
|
|
882
|
+
email: me.email,
|
|
883
|
+
name: me.name,
|
|
884
|
+
user_id: me.id
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
const globalProperties = {
|
|
888
|
+
$lib: "@lingo.dev/cli",
|
|
889
|
+
cli_version: VERSION,
|
|
890
|
+
os: process.platform,
|
|
891
|
+
arch: process.arch,
|
|
892
|
+
node_version: process.version,
|
|
893
|
+
is_ci: ciCtx.isCI,
|
|
894
|
+
ci_provider: ciCtx.provider,
|
|
895
|
+
ci_pr: ciCtx.isPR,
|
|
896
|
+
auth_method: creds?.type ?? null,
|
|
897
|
+
has_project_config: !!projectConfig,
|
|
898
|
+
org_id: projectConfig?.orgId ?? null,
|
|
899
|
+
engine_id: projectConfig?.engineId ?? null,
|
|
900
|
+
is_tty: process.stdout.isTTY ?? false,
|
|
901
|
+
shell: process.env.SHELL ?? null
|
|
902
|
+
};
|
|
903
|
+
const buffer = [];
|
|
904
|
+
let flushed = false;
|
|
905
|
+
return {
|
|
906
|
+
capture: (event, properties) => Effect.sync(() => {
|
|
907
|
+
if (flushed) return;
|
|
908
|
+
buffer.push({
|
|
909
|
+
event,
|
|
910
|
+
properties: {
|
|
911
|
+
...globalProperties,
|
|
912
|
+
...properties
|
|
913
|
+
},
|
|
914
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
915
|
+
});
|
|
916
|
+
}),
|
|
917
|
+
identify: (traits) => Effect.sync(() => {
|
|
918
|
+
if (traits) identifyTraits = {
|
|
919
|
+
...identifyTraits,
|
|
920
|
+
...traits
|
|
921
|
+
};
|
|
922
|
+
}),
|
|
923
|
+
shutdown: Effect.sync(() => {
|
|
924
|
+
if (flushed) return;
|
|
925
|
+
flushed = true;
|
|
926
|
+
if (buffer.length === 0 && !identifyTraits) return;
|
|
927
|
+
const payload = {
|
|
928
|
+
apiKey: POSTHOG_KEY,
|
|
929
|
+
host: POSTHOG_HOST,
|
|
930
|
+
distinctId,
|
|
931
|
+
events: buffer,
|
|
932
|
+
identifyTraits
|
|
933
|
+
};
|
|
934
|
+
const file = path.join(os.tmpdir(), `lingo-telemetry-${process.pid}-${Date.now()}.json`);
|
|
935
|
+
try {
|
|
936
|
+
fs.writeFileSync(file, JSON.stringify(payload));
|
|
937
|
+
} catch {
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
try {
|
|
941
|
+
spawn(process.execPath, [FLUSH_SCRIPT, file], {
|
|
942
|
+
detached: true,
|
|
943
|
+
stdio: "ignore",
|
|
944
|
+
windowsHide: true
|
|
945
|
+
}).unref();
|
|
946
|
+
} catch {
|
|
947
|
+
try {
|
|
948
|
+
fs.unlinkSync(file);
|
|
949
|
+
} catch {}
|
|
950
|
+
}
|
|
951
|
+
})
|
|
952
|
+
};
|
|
953
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(noop))));
|
|
954
|
+
function trackCommand(commandName, flags) {
|
|
955
|
+
return (effect) => {
|
|
956
|
+
return Effect.gen(function* () {
|
|
957
|
+
const telemetry = yield* TelemetryService;
|
|
958
|
+
const start = Date.now();
|
|
959
|
+
yield* telemetry.capture("cli_command_started", {
|
|
960
|
+
command: commandName,
|
|
961
|
+
flags
|
|
962
|
+
});
|
|
963
|
+
const result = yield* pipe(effect, Effect.tapError((error) => {
|
|
964
|
+
const tag = "_tag" in error ? error._tag : "UnknownError";
|
|
965
|
+
const cancelled = tag === "PromptCancelledError";
|
|
966
|
+
return telemetry.capture("cli_command_completed", {
|
|
967
|
+
command: commandName,
|
|
968
|
+
success: false,
|
|
969
|
+
cancelled,
|
|
970
|
+
duration_ms: Date.now() - start,
|
|
971
|
+
...cancelled ? {} : { error_type: tag }
|
|
972
|
+
});
|
|
973
|
+
}));
|
|
974
|
+
yield* telemetry.capture("cli_command_completed", {
|
|
975
|
+
command: commandName,
|
|
976
|
+
success: true,
|
|
977
|
+
duration_ms: Date.now() - start,
|
|
978
|
+
error_type: null
|
|
979
|
+
});
|
|
980
|
+
return result;
|
|
981
|
+
});
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
//#endregion
|
|
985
|
+
//#region src/commands/login.ts
|
|
986
|
+
const email = pipe(Options.text("email"), Options.withDescription("Email address (skips prompt)"), Options.optional);
|
|
987
|
+
const code = pipe(Options.text("code"), Options.withDescription("OTP code (skips prompt, requires --email)"), Options.optional);
|
|
988
|
+
const apiKey = pipe(Options.text("api-key"), Options.withDescription("Authenticate with an API key instead of email"), Options.optional);
|
|
989
|
+
const loginCommand = Command.make("login", {
|
|
990
|
+
email,
|
|
991
|
+
code,
|
|
992
|
+
apiKey
|
|
993
|
+
}, ({ email: emailOpt, code: codeOpt, apiKey: apiKeyOpt }) => Effect.gen(function* () {
|
|
994
|
+
const auth = yield* AuthService;
|
|
995
|
+
const apiKeyValue = Option.getOrUndefined(apiKeyOpt);
|
|
996
|
+
const emailFlag = Option.getOrUndefined(emailOpt);
|
|
997
|
+
const codeFlag = Option.getOrUndefined(codeOpt);
|
|
998
|
+
const telemetry = yield* TelemetryService;
|
|
999
|
+
if (apiKeyValue) {
|
|
1000
|
+
yield* auth.store({
|
|
1001
|
+
type: "api-key",
|
|
1002
|
+
apiKey: apiKeyValue
|
|
1003
|
+
});
|
|
1004
|
+
yield* telemetry.capture("cli_login", { method: "api-key" });
|
|
1005
|
+
yield* log.success("Authenticated with API key.");
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
yield* Effect.async((resume, signal) => {
|
|
1009
|
+
animateBanner(signal).then(() => resume(Effect.void));
|
|
1010
|
+
});
|
|
1011
|
+
yield* log.step(pc.dim(TAGLINE));
|
|
1012
|
+
const emailAddress = emailFlag ?? (yield* text({
|
|
1013
|
+
message: "Email",
|
|
1014
|
+
validate: (v) => !v.includes("@") ? "Enter a valid email address." : void 0
|
|
1015
|
+
}));
|
|
1016
|
+
const s = spinner();
|
|
1017
|
+
s.start("Sending code...");
|
|
1018
|
+
yield* sendOtp(emailAddress);
|
|
1019
|
+
s.stop(`Code sent to ${pc.green(emailAddress)}. Check your inbox.`);
|
|
1020
|
+
const otpCode = codeFlag ?? (yield* text({
|
|
1021
|
+
message: "Verification code",
|
|
1022
|
+
placeholder: "6-digit code from email"
|
|
1023
|
+
}));
|
|
1024
|
+
s.start("Verifying...");
|
|
1025
|
+
const session = yield* verifyOtp(emailAddress, otpCode);
|
|
1026
|
+
s.stop(`Authenticated as ${pc.green(emailAddress)}.`);
|
|
1027
|
+
const creds = {
|
|
1028
|
+
type: "session",
|
|
1029
|
+
email: session.user.email ?? emailAddress,
|
|
1030
|
+
accessToken: session.access_token,
|
|
1031
|
+
refreshToken: session.refresh_token,
|
|
1032
|
+
expiresAt: session.expires_at ?? Math.floor(Date.now() / 1e3) + session.expires_in
|
|
1033
|
+
};
|
|
1034
|
+
yield* auth.store(creds);
|
|
1035
|
+
yield* telemetry.capture("cli_login", { method: "otp" });
|
|
1036
|
+
yield* telemetry.identify({ email: creds.email });
|
|
1037
|
+
})).pipe(Command.withDescription("Authenticate with Lingo.dev"));
|
|
1038
|
+
//#endregion
|
|
1039
|
+
//#region src/commands/logout.ts
|
|
1040
|
+
const logoutCommand = Command.make("logout", {}, () => Effect.gen(function* () {
|
|
1041
|
+
const auth = yield* AuthService;
|
|
1042
|
+
const telemetry = yield* TelemetryService;
|
|
1043
|
+
yield* auth.clear;
|
|
1044
|
+
yield* telemetry.capture("cli_logout");
|
|
1045
|
+
yield* log.success("Logged out.");
|
|
1046
|
+
})).pipe(Command.withDescription("Clear stored credentials"));
|
|
1047
|
+
//#endregion
|
|
1048
|
+
//#region src/commands/whoami.ts
|
|
1049
|
+
const json = Options.boolean("json").pipe(Options.withDescription("Output as JSON"));
|
|
1050
|
+
const whoamiCommand = Command.make("whoami", { json }, ({ json }) => Effect.gen(function* () {
|
|
1051
|
+
const auth = yield* AuthService;
|
|
1052
|
+
const api = yield* ApiClient;
|
|
1053
|
+
const config = yield* ConfigService;
|
|
1054
|
+
const creds = yield* auth.resolve;
|
|
1055
|
+
const projectConfig = yield* config.resolve;
|
|
1056
|
+
const info = {};
|
|
1057
|
+
if (creds.type === "session") {
|
|
1058
|
+
const me = yield* pipe(api.getMe, Effect.catchAll(() => Effect.succeed(null)));
|
|
1059
|
+
info.email = me?.email ?? creds.email;
|
|
1060
|
+
if (me?.name) info.name = me.name;
|
|
1061
|
+
info.auth = "session";
|
|
1062
|
+
} else info.auth = "api-key";
|
|
1063
|
+
if (projectConfig) {
|
|
1064
|
+
info.orgId = projectConfig.orgId;
|
|
1065
|
+
info.engineId = projectConfig.engineId;
|
|
1066
|
+
if (creds.type === "session") {
|
|
1067
|
+
const org = (yield* pipe(api.listMemberships, Effect.catchAll(() => Effect.succeed([])))).find((m) => m.organizationId === projectConfig.orgId);
|
|
1068
|
+
if (org) info.org = org.organizationName;
|
|
1069
|
+
}
|
|
1070
|
+
if (info.orgId) {
|
|
1071
|
+
const eng = (yield* pipe(api.listEngines(info.orgId), Effect.catchAll(() => Effect.succeed([])))).find((e) => e.id === projectConfig.engineId);
|
|
1072
|
+
if (eng) info.engine = eng.name;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
if (json) yield* Console.log(JSON.stringify(info));
|
|
1076
|
+
else {
|
|
1077
|
+
const lines = [];
|
|
1078
|
+
if (info.email) lines.push(` ${pc.bold("Email:")} ${pc.green(info.email)}`);
|
|
1079
|
+
if (info.org) lines.push(` ${pc.bold("Org:")} ${info.org} ${pc.dim(`(${info.orgId})`)}`);
|
|
1080
|
+
else if (info.orgId) lines.push(` ${pc.bold("Org:")} ${pc.dim(info.orgId)}`);
|
|
1081
|
+
if (info.engine) lines.push(` ${pc.bold("Engine:")} ${info.engine} ${pc.dim(`(${info.engineId})`)}`);
|
|
1082
|
+
else if (info.engineId) lines.push(` ${pc.bold("Engine:")} ${pc.dim(info.engineId)}`);
|
|
1083
|
+
lines.push(` ${pc.bold("Auth:")} ${info.auth}`);
|
|
1084
|
+
yield* Console.log(lines.join("\n"));
|
|
1085
|
+
}
|
|
1086
|
+
})).pipe(Command.withDescription("Show current identity and context"));
|
|
1087
|
+
//#endregion
|
|
1088
|
+
//#region src/commands/link.ts
|
|
1089
|
+
const CREATE_NEW = "__create_new__";
|
|
1090
|
+
const org = pipe(Options.text("org"), Options.withDescription("Organization ID (skips prompt)"), Options.optional);
|
|
1091
|
+
const engine = pipe(Options.text("engine"), Options.withDescription("Engine ID (skips prompt)"), Options.optional);
|
|
1092
|
+
const linkCommand = Command.make("link", {
|
|
1093
|
+
org,
|
|
1094
|
+
engine
|
|
1095
|
+
}, ({ org: orgOpt, engine: engineOpt }) => Effect.gen(function* () {
|
|
1096
|
+
yield* (yield* AuthService).resolve;
|
|
1097
|
+
const api = yield* ApiClient;
|
|
1098
|
+
const config = yield* ConfigService;
|
|
1099
|
+
const telemetry = yield* TelemetryService;
|
|
1100
|
+
const orgFlag = Option.getOrUndefined(orgOpt);
|
|
1101
|
+
const engineFlag = Option.getOrUndefined(engineOpt);
|
|
1102
|
+
let orgId;
|
|
1103
|
+
let orgName;
|
|
1104
|
+
let createdOrg = false;
|
|
1105
|
+
let createdEngine = false;
|
|
1106
|
+
if (orgFlag) {
|
|
1107
|
+
orgId = orgFlag;
|
|
1108
|
+
orgName = orgFlag;
|
|
1109
|
+
} else {
|
|
1110
|
+
const memberships = yield* api.listMemberships;
|
|
1111
|
+
const selected = yield* select({
|
|
1112
|
+
message: "Organization",
|
|
1113
|
+
options: [...memberships.map((m) => ({
|
|
1114
|
+
value: m.organizationId,
|
|
1115
|
+
label: m.organizationName,
|
|
1116
|
+
hint: pc.dim(m.organizationId)
|
|
1117
|
+
})), {
|
|
1118
|
+
value: CREATE_NEW,
|
|
1119
|
+
label: pc.dim("Create new organization")
|
|
1120
|
+
}]
|
|
1121
|
+
});
|
|
1122
|
+
if (selected === CREATE_NEW) {
|
|
1123
|
+
const result = yield* createOrganization(api);
|
|
1124
|
+
orgId = result.orgId;
|
|
1125
|
+
orgName = result.orgName;
|
|
1126
|
+
createdOrg = true;
|
|
1127
|
+
} else {
|
|
1128
|
+
orgId = selected;
|
|
1129
|
+
orgName = memberships.find((m) => m.organizationId === orgId)?.organizationName ?? orgId;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
let engineId;
|
|
1133
|
+
let engineName;
|
|
1134
|
+
if (engineFlag) {
|
|
1135
|
+
engineId = engineFlag;
|
|
1136
|
+
engineName = engineFlag;
|
|
1137
|
+
} else {
|
|
1138
|
+
const engines = yield* api.listEngines(orgId);
|
|
1139
|
+
const selected = yield* select({
|
|
1140
|
+
message: "Localization engine",
|
|
1141
|
+
options: [...engines.map((e) => ({
|
|
1142
|
+
value: e.id,
|
|
1143
|
+
label: e.name,
|
|
1144
|
+
hint: e.description ? pc.dim(e.description) : void 0
|
|
1145
|
+
})), {
|
|
1146
|
+
value: CREATE_NEW,
|
|
1147
|
+
label: pc.dim("Create new engine")
|
|
1148
|
+
}]
|
|
1149
|
+
});
|
|
1150
|
+
if (selected === CREATE_NEW) {
|
|
1151
|
+
const result = yield* createEngine(api, orgId);
|
|
1152
|
+
engineId = result.engineId;
|
|
1153
|
+
engineName = result.engineName;
|
|
1154
|
+
createdEngine = true;
|
|
1155
|
+
} else {
|
|
1156
|
+
engineId = selected;
|
|
1157
|
+
engineName = engines.find((e) => e.id === engineId)?.name ?? engineId;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
yield* config.store({
|
|
1161
|
+
orgId,
|
|
1162
|
+
engineId
|
|
1163
|
+
});
|
|
1164
|
+
yield* telemetry.capture("cli_project_linked", {
|
|
1165
|
+
created_org: createdOrg,
|
|
1166
|
+
created_engine: createdEngine
|
|
1167
|
+
});
|
|
1168
|
+
yield* log.success(`Linked to ${pc.green(orgName)} / ${pc.green(engineName)}.`);
|
|
1169
|
+
yield* log.message(pc.dim("Config saved to .lingo/config.json. Commit this file to your repository."));
|
|
1170
|
+
})).pipe(Command.withDescription("Connect this directory to an org and engine"));
|
|
1171
|
+
function createOrganization(api) {
|
|
1172
|
+
return Effect.gen(function* () {
|
|
1173
|
+
const name = yield* text({
|
|
1174
|
+
message: "Organization name",
|
|
1175
|
+
placeholder: "Acme Inc"
|
|
1176
|
+
});
|
|
1177
|
+
const usecases = yield* multiselect({
|
|
1178
|
+
message: "What are you planning to use Lingo.dev for? (space to select, enter to confirm)",
|
|
1179
|
+
required: true,
|
|
1180
|
+
options: [
|
|
1181
|
+
{
|
|
1182
|
+
value: "web-app",
|
|
1183
|
+
label: "Web app localization"
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
value: "mobile-app",
|
|
1187
|
+
label: "Mobile app localization"
|
|
1188
|
+
},
|
|
1189
|
+
{
|
|
1190
|
+
value: "translation-api",
|
|
1191
|
+
label: "Translation API"
|
|
1192
|
+
},
|
|
1193
|
+
{
|
|
1194
|
+
value: "ci-cd",
|
|
1195
|
+
label: "CI/CD localization workflows"
|
|
1196
|
+
},
|
|
1197
|
+
{
|
|
1198
|
+
value: "static-content",
|
|
1199
|
+
label: "Static content localization (e.g. Markdown, JSON)"
|
|
1200
|
+
},
|
|
1201
|
+
{
|
|
1202
|
+
value: "cms",
|
|
1203
|
+
label: "CMS content localization"
|
|
1204
|
+
},
|
|
1205
|
+
{
|
|
1206
|
+
value: "emails",
|
|
1207
|
+
label: "Emails localization"
|
|
1208
|
+
}
|
|
1209
|
+
]
|
|
1210
|
+
});
|
|
1211
|
+
const attribution = yield* text({
|
|
1212
|
+
message: "How did you hear about us?",
|
|
1213
|
+
placeholder: "e.g. Twitter, Reddit, coworker...",
|
|
1214
|
+
validate: (v) => !v.trim() ? "This helps us build a better product." : void 0
|
|
1215
|
+
});
|
|
1216
|
+
yield* pipe(api.updateMe({
|
|
1217
|
+
usecases,
|
|
1218
|
+
selfAttribution: attribution.trim()
|
|
1219
|
+
}), Effect.catchAll(() => Effect.void));
|
|
1220
|
+
const s = spinner();
|
|
1221
|
+
s.start("Creating organization...");
|
|
1222
|
+
const created = yield* api.createOrganization(name);
|
|
1223
|
+
s.stop(`Created organization ${pc.green(created.name)}.`);
|
|
1224
|
+
return {
|
|
1225
|
+
orgId: created.id,
|
|
1226
|
+
orgName: created.name
|
|
1227
|
+
};
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
function createEngine(api, orgId) {
|
|
1231
|
+
return Effect.gen(function* () {
|
|
1232
|
+
yield* log.message(pc.dim("A localization engine is a stateful translation API you configure for your project."));
|
|
1233
|
+
const name = yield* text({
|
|
1234
|
+
message: "Engine name",
|
|
1235
|
+
placeholder: "default"
|
|
1236
|
+
});
|
|
1237
|
+
const s = spinner();
|
|
1238
|
+
s.start("Creating localization engine...");
|
|
1239
|
+
const created = yield* api.createEngine(orgId, name);
|
|
1240
|
+
s.stop(`Created localization engine ${pc.green(created.name)}.`);
|
|
1241
|
+
return {
|
|
1242
|
+
engineId: created.id,
|
|
1243
|
+
engineName: created.name
|
|
1244
|
+
};
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
//#endregion
|
|
1248
|
+
//#region src/commands/unlink.ts
|
|
1249
|
+
const unlinkCommand = Command.make("unlink", {}, () => Effect.gen(function* () {
|
|
1250
|
+
const config = yield* ConfigService;
|
|
1251
|
+
const telemetry = yield* TelemetryService;
|
|
1252
|
+
yield* config.clear;
|
|
1253
|
+
yield* telemetry.capture("cli_project_unlinked");
|
|
1254
|
+
yield* log.success("Unlinked.");
|
|
1255
|
+
})).pipe(Command.withDescription("Remove project config from this directory"));
|
|
1256
|
+
//#endregion
|
|
1257
|
+
//#region src/commands/update.ts
|
|
1258
|
+
const updateCommand = Command.make("update", {}, () => Effect.gen(function* () {
|
|
1259
|
+
const update = yield* UpdateService;
|
|
1260
|
+
const telemetry = yield* TelemetryService;
|
|
1261
|
+
yield* log.step(`Current version: ${pc.dim(`v${VERSION}`)}`);
|
|
1262
|
+
const info = yield* update.check;
|
|
1263
|
+
if (!info) {
|
|
1264
|
+
yield* log.success("Already up to date.");
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
yield* log.info(`Update available: ${pc.dim(info.current)} → ${pc.green(info.latest)}`);
|
|
1268
|
+
if (info.isMajor) yield* log.warn("This is a major version update. Review the changelog before upgrading.");
|
|
1269
|
+
yield* telemetry.capture("cli_update_started", {
|
|
1270
|
+
from_version: info.current,
|
|
1271
|
+
to_version: info.latest,
|
|
1272
|
+
install_method: update.installMethod.type
|
|
1273
|
+
});
|
|
1274
|
+
const s = spinner();
|
|
1275
|
+
s.start("Updating...");
|
|
1276
|
+
const result = yield* Effect.either(update.execute());
|
|
1277
|
+
if (result._tag === "Left") {
|
|
1278
|
+
s.stop("Update failed.");
|
|
1279
|
+
const error = result.left;
|
|
1280
|
+
if (error instanceof UpdateError) yield* log.info(error.message);
|
|
1281
|
+
yield* telemetry.capture("cli_update_completed", {
|
|
1282
|
+
from_version: info.current,
|
|
1283
|
+
to_version: info.latest,
|
|
1284
|
+
success: false
|
|
1285
|
+
});
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
s.stop("Updated successfully.");
|
|
1289
|
+
yield* telemetry.capture("cli_update_completed", {
|
|
1290
|
+
from_version: info.current,
|
|
1291
|
+
to_version: info.latest,
|
|
1292
|
+
success: true
|
|
1293
|
+
});
|
|
1294
|
+
})).pipe(Command.withDescription("Update the CLI to the latest version"));
|
|
1295
|
+
//#endregion
|
|
1296
|
+
//#region src/cli.ts
|
|
1297
|
+
const subcommands = [
|
|
1298
|
+
extractCommand,
|
|
1299
|
+
localizeCommand,
|
|
1300
|
+
shipCommand,
|
|
1301
|
+
statusCommand,
|
|
1302
|
+
checkCommand,
|
|
1303
|
+
guideCommand,
|
|
1304
|
+
installCommand,
|
|
1305
|
+
loginCommand,
|
|
1306
|
+
logoutCommand,
|
|
1307
|
+
whoamiCommand,
|
|
1308
|
+
linkCommand,
|
|
1309
|
+
unlinkCommand,
|
|
1310
|
+
updateCommand
|
|
1311
|
+
];
|
|
1312
|
+
const command = Command.make("lingo").pipe(Command.withDescription(TAGLINE), Command.withSubcommands([
|
|
1313
|
+
extractCommand,
|
|
1314
|
+
localizeCommand,
|
|
1315
|
+
shipCommand,
|
|
1316
|
+
statusCommand,
|
|
1317
|
+
checkCommand,
|
|
1318
|
+
guideCommand,
|
|
1319
|
+
installCommand,
|
|
1320
|
+
loginCommand,
|
|
1321
|
+
logoutCommand,
|
|
1322
|
+
whoamiCommand,
|
|
1323
|
+
linkCommand,
|
|
1324
|
+
unlinkCommand,
|
|
1325
|
+
updateCommand
|
|
1326
|
+
]));
|
|
1327
|
+
const cliConfig = CliConfig.make({
|
|
1328
|
+
showBuiltIns: false,
|
|
1329
|
+
showTypes: false
|
|
1330
|
+
});
|
|
1331
|
+
const helpConfig = {
|
|
1332
|
+
name: "lingo",
|
|
1333
|
+
version: VERSION
|
|
1334
|
+
};
|
|
1335
|
+
const run = Command.run(command, {
|
|
1336
|
+
name: helpConfig.name,
|
|
1337
|
+
version: helpConfig.version
|
|
1338
|
+
});
|
|
1339
|
+
//#endregion
|
|
1340
|
+
//#region src/help/descriptor.ts
|
|
1341
|
+
function unwrapCommand(desc) {
|
|
1342
|
+
if (desc._tag === "Map") return unwrapCommand(desc.command);
|
|
1343
|
+
return desc;
|
|
1344
|
+
}
|
|
1345
|
+
function extractCommandInfo(desc) {
|
|
1346
|
+
const cmd = unwrapCommand(desc);
|
|
1347
|
+
return {
|
|
1348
|
+
name: cmd.name,
|
|
1349
|
+
description: extractDescription(cmd.description)
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
function collectArgs(node) {
|
|
1353
|
+
if (!node) return [];
|
|
1354
|
+
switch (node._tag) {
|
|
1355
|
+
case "Empty": return [];
|
|
1356
|
+
case "Single": return [{
|
|
1357
|
+
name: extractPseudoName(node) || "arg",
|
|
1358
|
+
description: extractDescription(node.description),
|
|
1359
|
+
required: true
|
|
1360
|
+
}];
|
|
1361
|
+
case "Map": return collectArgs(node.args);
|
|
1362
|
+
case "WithDefault": {
|
|
1363
|
+
const inner = collectArgs(node.args);
|
|
1364
|
+
const fallback = formatFallback(node.fallback);
|
|
1365
|
+
return inner.map((a) => ({
|
|
1366
|
+
...a,
|
|
1367
|
+
required: false,
|
|
1368
|
+
defaultValue: fallback
|
|
1369
|
+
}));
|
|
1370
|
+
}
|
|
1371
|
+
case "Both": return [...collectArgs(node.left), ...collectArgs(node.right)];
|
|
1372
|
+
case "Variadic": return collectArgs(node.args).map((a) => ({
|
|
1373
|
+
...a,
|
|
1374
|
+
required: false
|
|
1375
|
+
}));
|
|
1376
|
+
default: return [];
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
function collectOptions(node) {
|
|
1380
|
+
if (!node) return [];
|
|
1381
|
+
switch (node._tag) {
|
|
1382
|
+
case "Empty": return [];
|
|
1383
|
+
case "Single": return [singleToOptionInfo(node, true)];
|
|
1384
|
+
case "Map": return collectOptions(node.options);
|
|
1385
|
+
case "WithDefault": {
|
|
1386
|
+
const inner = collectOptions(node.options);
|
|
1387
|
+
const fallback = formatFallback(node.fallback);
|
|
1388
|
+
return inner.map((o) => ({
|
|
1389
|
+
...o,
|
|
1390
|
+
required: false,
|
|
1391
|
+
defaultValue: o.isBool ? void 0 : fallback
|
|
1392
|
+
}));
|
|
1393
|
+
}
|
|
1394
|
+
case "Both": return [...collectOptions(node.left), ...collectOptions(node.right)];
|
|
1395
|
+
case "Variadic": return collectOptions(node.argumentOption).map((o) => ({
|
|
1396
|
+
...o,
|
|
1397
|
+
required: false,
|
|
1398
|
+
repeatable: true
|
|
1399
|
+
}));
|
|
1400
|
+
default: return [];
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
function singleToOptionInfo(node, required) {
|
|
1404
|
+
const prim = node.primitiveType;
|
|
1405
|
+
const isBool = prim?._tag === "Bool";
|
|
1406
|
+
return {
|
|
1407
|
+
long: node.name,
|
|
1408
|
+
short: node.aliases?.[0],
|
|
1409
|
+
type: isBool ? "" : primTypeName(prim),
|
|
1410
|
+
description: extractDescription(node.description),
|
|
1411
|
+
required,
|
|
1412
|
+
repeatable: false,
|
|
1413
|
+
isBool
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
function primTypeName(prim) {
|
|
1417
|
+
if (!prim) return "value";
|
|
1418
|
+
switch (prim._tag) {
|
|
1419
|
+
case "Text": return "str";
|
|
1420
|
+
case "Integer": return "int";
|
|
1421
|
+
case "Float": return "num";
|
|
1422
|
+
case "Date": return "date";
|
|
1423
|
+
case "Bool": return "";
|
|
1424
|
+
case "Choice": return prim.alternatives.map((a) => a[0]).join(" | ");
|
|
1425
|
+
default: return "value";
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
function extractDescription(helpDoc) {
|
|
1429
|
+
if (!helpDoc) return "";
|
|
1430
|
+
return extractHelpDocText(helpDoc).trim();
|
|
1431
|
+
}
|
|
1432
|
+
function extractHelpDocText(doc) {
|
|
1433
|
+
if (!doc) return "";
|
|
1434
|
+
switch (doc._tag) {
|
|
1435
|
+
case "Empty": return "";
|
|
1436
|
+
case "Paragraph": return extractSpanText(doc.value);
|
|
1437
|
+
case "Sequence": return [extractHelpDocText(doc.left), extractHelpDocText(doc.right)].filter(Boolean).join(" ");
|
|
1438
|
+
case "Header": return extractSpanText(doc.value);
|
|
1439
|
+
default: return "";
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
function extractSpanText(span) {
|
|
1443
|
+
if (!span) return "";
|
|
1444
|
+
switch (span._tag) {
|
|
1445
|
+
case "Text": return span.value;
|
|
1446
|
+
case "Strong":
|
|
1447
|
+
case "Weak":
|
|
1448
|
+
case "Highlight": return extractSpanText(span.value);
|
|
1449
|
+
case "URI": return span.value;
|
|
1450
|
+
case "Sequence": return extractSpanText(span.left) + extractSpanText(span.right);
|
|
1451
|
+
default: return "";
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
function extractPseudoName(node) {
|
|
1455
|
+
const pn = node.pseudoName;
|
|
1456
|
+
if (pn && pn._tag === "Some") return pn.value;
|
|
1457
|
+
return node.name?.replace(/[<>]/g, "");
|
|
1458
|
+
}
|
|
1459
|
+
function formatFallback(fallback) {
|
|
1460
|
+
if (fallback === null || fallback === void 0) return void 0;
|
|
1461
|
+
if (typeof fallback === "object" && "_tag" in fallback) {
|
|
1462
|
+
if (fallback._tag === "None") return void 0;
|
|
1463
|
+
if (fallback._tag === "Some") return formatFallback(fallback.value);
|
|
1464
|
+
}
|
|
1465
|
+
if (typeof fallback === "boolean") return fallback ? "true" : "false";
|
|
1466
|
+
return String(fallback);
|
|
1467
|
+
}
|
|
1468
|
+
//#endregion
|
|
1469
|
+
//#region src/help/renderer.ts
|
|
1470
|
+
function renderRootHelp(config, subcommandDescriptors) {
|
|
1471
|
+
const rootInfo = { name: config.name };
|
|
1472
|
+
const subs = subcommandDescriptors.map((d) => extractCommandInfo(d));
|
|
1473
|
+
const lines = [];
|
|
1474
|
+
lines.push(`${pc.bold(rootInfo.name)} ${pc.dim(`v${config.version}`)}`);
|
|
1475
|
+
lines.push("");
|
|
1476
|
+
lines.push(`${pc.bold("Usage:")} ${rootInfo.name} <command> [flags]`);
|
|
1477
|
+
if (subs.length > 0) {
|
|
1478
|
+
lines.push("");
|
|
1479
|
+
lines.push(pc.bold("Commands:"));
|
|
1480
|
+
const maxLen = Math.max(...subs.map((s) => s.name.length));
|
|
1481
|
+
for (const sub of subs) lines.push(` ${pc.green(sub.name.padEnd(maxLen))} ${pc.dim(sub.description)}`);
|
|
1482
|
+
}
|
|
1483
|
+
lines.push("");
|
|
1484
|
+
lines.push(pc.dim(`Run ${rootInfo.name} <command> --help for more information.`));
|
|
1485
|
+
return lines.join("\n");
|
|
1486
|
+
}
|
|
1487
|
+
function renderCommandHelp(config, descriptor) {
|
|
1488
|
+
const cmd = unwrapCommand(descriptor);
|
|
1489
|
+
const info = extractCommandInfo(descriptor);
|
|
1490
|
+
const args = collectArgs(cmd.args);
|
|
1491
|
+
const opts = collectOptions(cmd.options);
|
|
1492
|
+
const lines = [];
|
|
1493
|
+
lines.push(`${pc.bold(config.name)} ${pc.green(info.name)}${info.description ? ` ${pc.dim("-")} ${info.description}` : ""}`);
|
|
1494
|
+
lines.push("");
|
|
1495
|
+
const usageParts = [config.name, info.name];
|
|
1496
|
+
for (const arg of args) usageParts.push(arg.required ? `<${arg.name}>` : `[${arg.name}]`);
|
|
1497
|
+
if (opts.length > 0) usageParts.push("[flags]");
|
|
1498
|
+
lines.push(`${pc.bold("Usage:")} ${usageParts.join(" ")}`);
|
|
1499
|
+
if (args.length > 0) {
|
|
1500
|
+
lines.push("");
|
|
1501
|
+
lines.push(pc.bold("Arguments:"));
|
|
1502
|
+
const maxLen = Math.max(...args.map((a) => a.name.length));
|
|
1503
|
+
for (const arg of args) {
|
|
1504
|
+
const name = pc.green(arg.name.padEnd(maxLen));
|
|
1505
|
+
const parts = [arg.description];
|
|
1506
|
+
if (arg.defaultValue !== void 0) parts.push(pc.dim(`[default: ${arg.defaultValue}]`));
|
|
1507
|
+
lines.push(` ${name} ${parts.filter(Boolean).join(" ")}`);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
if (opts.length > 0) {
|
|
1511
|
+
lines.push("");
|
|
1512
|
+
lines.push(pc.bold("Options:"));
|
|
1513
|
+
const signatures = opts.map(formatOptionSignature);
|
|
1514
|
+
const maxLen = Math.max(...signatures.map((s) => stripAnsi(s).length));
|
|
1515
|
+
for (let i = 0; i < opts.length; i++) {
|
|
1516
|
+
const sig = signatures[i];
|
|
1517
|
+
const pad = " ".repeat(maxLen - stripAnsi(sig).length);
|
|
1518
|
+
const parts = [opts[i].description];
|
|
1519
|
+
if (opts[i].defaultValue !== void 0) parts.push(pc.dim(`[default: ${opts[i].defaultValue}]`));
|
|
1520
|
+
if (opts[i].repeatable) parts.push(pc.dim("(repeatable)"));
|
|
1521
|
+
lines.push(` ${sig}${pad} ${parts.filter(Boolean).join(" ")}`);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
return lines.join("\n");
|
|
1525
|
+
}
|
|
1526
|
+
function formatOptionSignature(opt) {
|
|
1527
|
+
return `${opt.short ? `${pc.yellow(`-${opt.short}`)}, ` : " "}${pc.yellow(`--${opt.long}`)}${opt.type ? ` ${pc.dim(`<${opt.type}>`)}` : ""}`;
|
|
1528
|
+
}
|
|
1529
|
+
function stripAnsi(str) {
|
|
1530
|
+
return str.replace(/\u001b\[[0-9;]*m/g, "");
|
|
1531
|
+
}
|
|
1532
|
+
//#endregion
|
|
1533
|
+
export { AuthService as C, AuthError as S, ConfigError as _, extractCommandInfo as a, ApiClientLive as b, command as c, subcommands as d, TelemetryService as f, cancel as g, PromptCancelledError as h, collectOptions as i, helpConfig as l, trackCommand as m, renderRootHelp as n, unwrapCommand as o, TelemetryServiceLive as p, collectArgs as r, cliConfig as s, renderCommandHelp as t, run as u, ConfigService as v, AuthServiceLive as w, AuthContext as x, ConfigServiceLive as y };
|