@factorialco/fcode-cli 2.0.0 → 2.2.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.
Files changed (76) hide show
  1. package/README.md +248 -2
  2. package/dist/commands/add.js +3 -1
  3. package/dist/commands/clone.d.ts +1 -0
  4. package/dist/commands/clone.js +5 -0
  5. package/dist/commands/i18n/add.d.ts +9 -0
  6. package/dist/commands/i18n/add.js +13 -0
  7. package/dist/commands/i18n/pull.d.ts +12 -0
  8. package/dist/commands/i18n/pull.js +14 -0
  9. package/dist/commands/i18n/push.d.ts +12 -0
  10. package/dist/commands/i18n/push.js +14 -0
  11. package/dist/commands/i18n/remove.d.ts +9 -0
  12. package/dist/commands/i18n/remove.js +19 -0
  13. package/dist/commands/i18n/reset.d.ts +9 -0
  14. package/dist/commands/i18n/reset.js +13 -0
  15. package/dist/commands/i18n/status.d.ts +11 -0
  16. package/dist/commands/i18n/status.js +42 -0
  17. package/dist/commands/pull.js +5 -3
  18. package/dist/commands/push.js +5 -3
  19. package/dist/commands/reset.js +3 -1
  20. package/dist/commands/run.d.ts +1 -0
  21. package/dist/commands/run.js +5 -2
  22. package/dist/commands/setup-debug.js +2 -2
  23. package/dist/commands/status.js +4 -2
  24. package/dist/commands/team/aliases/delete.d.ts +9 -0
  25. package/dist/commands/team/aliases/delete.js +28 -0
  26. package/dist/commands/team/aliases/list.d.ts +6 -0
  27. package/dist/commands/team/aliases/list.js +23 -0
  28. package/dist/commands/team/aliases/set.d.ts +10 -0
  29. package/dist/commands/team/aliases/set.js +25 -0
  30. package/dist/commands/team/pull.js +8 -1
  31. package/dist/commands/team/push.js +6 -0
  32. package/dist/commands/team/versions/create.d.ts +13 -0
  33. package/dist/commands/team/versions/create.js +42 -0
  34. package/dist/commands/team/versions/delete.d.ts +12 -0
  35. package/dist/commands/team/versions/delete.js +32 -0
  36. package/dist/commands/team/versions/list.d.ts +6 -0
  37. package/dist/commands/team/versions/list.js +25 -0
  38. package/dist/commands/variables/status.js +11 -0
  39. package/dist/config/remote.js +1 -1
  40. package/dist/config/settings.d.ts +1 -0
  41. package/dist/config/settings.js +6 -0
  42. package/dist/config/workspace.js +10 -0
  43. package/dist/core/commands/i18n-command.d.ts +5 -0
  44. package/dist/core/commands/i18n-command.js +16 -0
  45. package/dist/core/errors.d.ts +6 -0
  46. package/dist/core/errors.js +19 -1
  47. package/dist/core/executors/execution-task.js +4 -0
  48. package/dist/core/executors/javascript-execution-task.js +26 -1
  49. package/dist/core/resource-pusher.js +15 -0
  50. package/dist/core/types.d.ts +66 -2
  51. package/dist/core/types.js +45 -3
  52. package/dist/env.d.ts +2 -2
  53. package/dist/env.js +1 -1
  54. package/dist/repositories/locale.d.ts +24 -0
  55. package/dist/repositories/locale.js +102 -0
  56. package/dist/repositories/resource.js +4 -1
  57. package/dist/repositories/team.d.ts +7 -1
  58. package/dist/repositories/team.js +39 -2
  59. package/dist/repositories/variable.d.ts +24 -0
  60. package/dist/repositories/variable.js +70 -22
  61. package/dist/runtime/executor.js +2 -2
  62. package/dist/runtime/executor.py +2 -2
  63. package/dist/runtime/libs/python/fcode/__init__.py +3 -2
  64. package/dist/runtime/libs/python/fcode/context.py +43 -1
  65. package/dist/services/fcode-graphql-client.d.ts +20 -1
  66. package/dist/services/fcode-graphql-client.js +276 -17
  67. package/dist/services/locale-merger.d.ts +23 -0
  68. package/dist/services/locale-merger.js +111 -0
  69. package/dist/services/locale-resolver.d.ts +18 -0
  70. package/dist/services/locale-resolver.js +120 -0
  71. package/dist/services/process-runner.d.ts +1 -0
  72. package/dist/services/process-runner.js +2 -2
  73. package/dist/utils/gitignore.d.ts +2 -0
  74. package/dist/utils/gitignore.js +9 -0
  75. package/oclif.manifest.json +511 -127
  76. package/package.json +11 -2
@@ -2,7 +2,33 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const graphql_request_1 = require("graphql-request");
4
4
  const types_1 = require("../core/types");
5
+ const locale_merger_1 = require("./locale-merger");
5
6
  const _ = require("lodash");
7
+ // Shared team version selections so every query/mutation returns the same shape.
8
+ const TEAM_VERSION_FIELDS = `
9
+ id
10
+ tag
11
+ comment
12
+ createdAt
13
+ createdBy {
14
+ id
15
+ name
16
+ }
17
+ manifest {
18
+ resourceType
19
+ resourceId
20
+ slug
21
+ outcome
22
+ error
23
+ }
24
+ `;
25
+ const TEAM_VERSION_ALIAS_FIELDS = `
26
+ id
27
+ name
28
+ tag
29
+ createdAt
30
+ updatedAt
31
+ `;
6
32
  // Shared process selection so pull queries and push mutations return the same
7
33
  // fields, keeping mapProcess (and therefore contentHash) consistent.
8
34
  const PROCESS_FIELDS = `
@@ -236,30 +262,148 @@ class FcodeGraphQLClient {
236
262
  return Promise.reject(this.cleanError(error));
237
263
  });
238
264
  }
239
- allVariables() {
240
- return this.client
241
- .request((0, graphql_request_1.gql) `
242
- query teamVariables($first: Int!, $after: String) {
243
- teamVariables(first: $first, after: $after) {
244
- edges {
245
- node {
246
- id
247
- key
248
- value
249
- isSensitive
265
+ // Pages through every variable: a workspace lists its own plus the ones inherited from
266
+ // its parent workspaces, so a single page is not enough to be sure nothing is missed.
267
+ async allVariables() {
268
+ const pageSize = 200;
269
+ const variables = [];
270
+ let after = null;
271
+ try {
272
+ do {
273
+ const { teamVariables: { edges, pageInfo }, } = await this.client.request((0, graphql_request_1.gql) `
274
+ query teamVariables($first: Int!, $after: String) {
275
+ teamVariables(first: $first, after: $after) {
276
+ edges {
277
+ node {
278
+ id
279
+ key
280
+ value
281
+ isSensitive
282
+ resolvingTeamSlug
283
+ }
284
+ }
285
+ pageInfo {
286
+ hasNextPage
287
+ endCursor
250
288
  }
251
289
  }
252
- pageInfo {
253
- hasNextPage
254
- endCursor
290
+ }
291
+ `, {
292
+ after,
293
+ first: pageSize,
294
+ });
295
+ for (const { node: { id, isSensitive, key, value, resolvingTeamSlug }, } of edges) {
296
+ variables.push(new types_1.Variable(id, key, value, isSensitive, this.inheritedTeam(resolvingTeamSlug)));
297
+ }
298
+ after = pageInfo?.hasNextPage ? pageInfo.endCursor : null;
299
+ } while (after);
300
+ }
301
+ catch (error) {
302
+ return Promise.reject(this.cleanError(error));
303
+ }
304
+ return variables;
305
+ }
306
+ /**
307
+ * Every locale the workspace resolves: its own files, plus one merged file per identifier it
308
+ * inherits. A locale the workspace and a parent both define is listed twice by the platform and
309
+ * kept twice here — an override is layered over the parent's file key by key, so the parent's
310
+ * keys are still what the ones it does not mention resolve to.
311
+ */
312
+ async allLocales() {
313
+ const pageSize = 200;
314
+ const locales = [];
315
+ let after = null;
316
+ try {
317
+ do {
318
+ const { locales: { edges, pageInfo }, } = await this.client.request((0, graphql_request_1.gql) `
319
+ query locales($first: Int!, $after: String) {
320
+ locales(first: $first, after: $after) {
321
+ edges {
322
+ node {
323
+ id
324
+ locale
325
+ content
326
+ resolvingTeamSlug
327
+ }
328
+ }
329
+ pageInfo {
330
+ hasNextPage
331
+ endCursor
332
+ }
255
333
  }
256
334
  }
335
+ `, {
336
+ after,
337
+ first: pageSize,
338
+ });
339
+ for (const { node: { id, locale, content, resolvingTeamSlug }, } of edges) {
340
+ locales.push(new types_1.Locale(id, locale, content, this.inheritedTeam(resolvingTeamSlug)));
341
+ }
342
+ after = pageInfo?.hasNextPage ? pageInfo.endCursor : null;
343
+ } while (after);
344
+ }
345
+ catch (error) {
346
+ return Promise.reject(this.cleanError(error));
347
+ }
348
+ const merged = (0, locale_merger_1.mergeInheritedLocales)(locales);
349
+ (0, locale_merger_1.assertLocaleFilesCanCoexist)(merged);
350
+ return merged;
351
+ }
352
+ pushLocale(locale) {
353
+ return this.client
354
+ .request((0, graphql_request_1.gql) `
355
+ mutation createLocale($locale: String!, $content: String!) {
356
+ createLocale(locale: $locale, content: $content) {
357
+ id
358
+ locale
359
+ content
360
+ }
257
361
  }
258
362
  `, {
259
- after: null,
260
- first: 200,
363
+ locale: locale.locale,
364
+ content: locale.content,
365
+ })
366
+ .then(({ createLocale: created }) => new types_1.Locale(created.id, created.locale, created.content))
367
+ .catch((error) => {
368
+ return Promise.reject(this.cleanError(error));
369
+ });
370
+ }
371
+ updateLocale(locale) {
372
+ return this.client
373
+ .request((0, graphql_request_1.gql) `
374
+ mutation updateLocale($locale: String!, $content: String!) {
375
+ updateLocale(locale: $locale, content: $content) {
376
+ id
377
+ locale
378
+ content
379
+ }
380
+ }
381
+ `, {
382
+ locale: locale.locale,
383
+ content: locale.content,
261
384
  })
262
- .then(({ teamVariables: { edges } }) => edges.map(({ node: { id, isSensitive, key, value } }) => new types_1.Variable(id, key, value, isSensitive)))
385
+ .then(({ updateLocale: updated }) => new types_1.Locale(updated.id, updated.locale, updated.content))
386
+ .catch((error) => {
387
+ return Promise.reject(this.cleanError(error));
388
+ });
389
+ }
390
+ // Addressed by identifier rather than id: a locale is its file, and the delete path is reached
391
+ // from a file that is gone, whose recorded id may predate an override.
392
+ removeLocale(locale) {
393
+ if (!locale) {
394
+ return Promise.reject(new Error('locale is required during remove'));
395
+ }
396
+ return this.client
397
+ .request((0, graphql_request_1.gql) `
398
+ mutation deleteLocale($locale: String!) {
399
+ deleteLocale(locale: $locale) {
400
+ id
401
+ }
402
+ }
403
+ `, {
404
+ locale,
405
+ })
406
+ .then(() => undefined)
263
407
  .catch((error) => {
264
408
  return Promise.reject(this.cleanError(error));
265
409
  });
@@ -601,8 +745,121 @@ class FcodeGraphQLClient {
601
745
  settings.webhookAuth.headerName = webhookAuth.headerName;
602
746
  }
603
747
  }
748
+ if (team.settings?.primaryLocale) {
749
+ settings.primaryLocale = team.settings.primaryLocale;
750
+ }
604
751
  return settings;
605
752
  }
753
+ mapTeamVersion(teamVersion) {
754
+ return {
755
+ id: teamVersion.id,
756
+ tag: teamVersion.tag,
757
+ comment: teamVersion.comment ?? null,
758
+ manifest: (teamVersion.manifest ?? []).map((entry) => ({
759
+ resourceType: entry.resourceType,
760
+ resourceId: entry.resourceId,
761
+ slug: entry.slug,
762
+ outcome: entry.outcome,
763
+ error: entry.error ?? null,
764
+ })),
765
+ createdAt: teamVersion.createdAt,
766
+ createdBy: teamVersion.createdBy ?? null,
767
+ };
768
+ }
769
+ mapTeamVersionAlias(alias) {
770
+ return {
771
+ id: alias.id,
772
+ name: alias.name,
773
+ tag: alias.tag,
774
+ createdAt: alias.createdAt,
775
+ updatedAt: alias.updatedAt ?? null,
776
+ };
777
+ }
778
+ allTeamVersions() {
779
+ return this.client
780
+ .request((0, graphql_request_1.gql) `
781
+ query allTeamVersions {
782
+ allTeamVersions {
783
+ ${TEAM_VERSION_FIELDS}
784
+ }
785
+ }
786
+ `)
787
+ .then(({ allTeamVersions }) => allTeamVersions.map((teamVersion) => this.mapTeamVersion(teamVersion)))
788
+ .catch((error) => {
789
+ return Promise.reject(this.cleanError(error));
790
+ });
791
+ }
792
+ createTeamVersion(tag, comment) {
793
+ return this.client
794
+ .request((0, graphql_request_1.gql) `
795
+ mutation createTeamVersion($tag: String!, $comment: String) {
796
+ createTeamVersion(tag: $tag, comment: $comment) {
797
+ ${TEAM_VERSION_FIELDS}
798
+ }
799
+ }
800
+ `, { tag, comment: comment ?? null })
801
+ .then(({ createTeamVersion }) => this.mapTeamVersion(createTeamVersion))
802
+ .catch((error) => {
803
+ return Promise.reject(this.cleanError(error));
804
+ });
805
+ }
806
+ deleteTeamVersion(tag) {
807
+ return this.client
808
+ .request((0, graphql_request_1.gql) `
809
+ mutation deleteTeamVersion($tag: String!) {
810
+ deleteTeamVersion(tag: $tag) {
811
+ ${TEAM_VERSION_FIELDS}
812
+ }
813
+ }
814
+ `, { tag })
815
+ .then(({ deleteTeamVersion }) => this.mapTeamVersion(deleteTeamVersion))
816
+ .catch((error) => {
817
+ return Promise.reject(this.cleanError(error));
818
+ });
819
+ }
820
+ allTeamVersionAliases() {
821
+ return this.client
822
+ .request((0, graphql_request_1.gql) `
823
+ query allTeamVersionAliases {
824
+ allTeamVersionAliases {
825
+ ${TEAM_VERSION_ALIAS_FIELDS}
826
+ }
827
+ }
828
+ `)
829
+ .then(({ allTeamVersionAliases }) => allTeamVersionAliases.map((alias) => this.mapTeamVersionAlias(alias)))
830
+ .catch((error) => {
831
+ return Promise.reject(this.cleanError(error));
832
+ });
833
+ }
834
+ /** The API upserts: an existing alias with the same name is re-pointed to the tag. */
835
+ createTeamVersionAlias(name, tag) {
836
+ return this.client
837
+ .request((0, graphql_request_1.gql) `
838
+ mutation createTeamVersionAlias($input: TeamVersionAliasInput!) {
839
+ createTeamVersionAlias(input: $input) {
840
+ ${TEAM_VERSION_ALIAS_FIELDS}
841
+ }
842
+ }
843
+ `, { input: { name, tag } })
844
+ .then(({ createTeamVersionAlias }) => this.mapTeamVersionAlias(createTeamVersionAlias))
845
+ .catch((error) => {
846
+ return Promise.reject(this.cleanError(error));
847
+ });
848
+ }
849
+ deleteTeamVersionAlias(id) {
850
+ return this.client
851
+ .request((0, graphql_request_1.gql) `
852
+ mutation deleteTeamVersionAlias($id: ID!) {
853
+ deleteTeamVersionAlias(id: $id) {
854
+ ${TEAM_VERSION_ALIAS_FIELDS}
855
+ }
856
+ }
857
+ `, { id })
858
+ .then(({ deleteTeamVersionAlias }) => this.mapTeamVersionAlias(deleteTeamVersionAlias))
859
+ .catch((error) => {
860
+ return Promise.reject(this.cleanError(error));
861
+ });
862
+ }
606
863
  team() {
607
864
  return this.client
608
865
  .request((0, graphql_request_1.gql) `
@@ -620,6 +877,7 @@ class FcodeGraphQLClient {
620
877
  headerName
621
878
  variableKey
622
879
  }
880
+ primaryLocale
623
881
  }
624
882
  }
625
883
  }
@@ -646,6 +904,7 @@ class FcodeGraphQLClient {
646
904
  headerName
647
905
  variableKey
648
906
  }
907
+ primaryLocale
649
908
  }
650
909
  }
651
910
  }
@@ -0,0 +1,23 @@
1
+ import { Locale } from '../core/types';
2
+ /**
3
+ * Collapses the locale files a workspace inherits into one file per identifier.
4
+ *
5
+ * The cloud lists a locale once per workspace that defines it, and every one of them is live: an
6
+ * override is layered over its parent key by key, so a locale several workspaces define is several
7
+ * files. A workspace directory keeps one inherited file per identifier instead — one file cannot
8
+ * hold two names — so the parents' files are merged here, in the order the cloud resolves them.
9
+ *
10
+ * A single parent's file is kept verbatim, comments and nesting included. Only a locale that
11
+ * several parents define is rewritten, as a flat mapping of dotted keys, which is the shape the
12
+ * lookup uses anyway and the one shape where a nested and a dotted key cannot disagree.
13
+ */
14
+ export declare function mergeInheritedLocales(locales: Array<Locale>): Array<Locale>;
15
+ /**
16
+ * Locale identifiers are case-sensitive on the platform (pt-BR and pt-br are different locales),
17
+ * but the filesystems macOS and Windows usually run on are not — both identifiers would collapse
18
+ * into one file, so a pull would write one locale over the other, `status` would report a phantom
19
+ * change forever, and the next push would silently overwrite one locale's cloud content with the
20
+ * other's. Refused outright before anything is written; on a case-sensitive filesystem the files
21
+ * live side by side and nothing is refused.
22
+ */
23
+ export declare function assertLocaleFilesCanCoexist(locales: Array<Locale>, filesystemKeepsCase?: boolean): void;
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mergeInheritedLocales = mergeInheritedLocales;
4
+ exports.assertLocaleFilesCanCoexist = assertLocaleFilesCanCoexist;
5
+ const fs = require("fs-extra");
6
+ const os = require("node:os");
7
+ const path = require("node:path");
8
+ const yaml_1 = require("yaml");
9
+ const color_1 = require("@oclif/color");
10
+ const cli_1 = require("../utils/cli");
11
+ const types_1 = require("../core/types");
12
+ const locale_resolver_1 = require("./locale-resolver");
13
+ /**
14
+ * Collapses the locale files a workspace inherits into one file per identifier.
15
+ *
16
+ * The cloud lists a locale once per workspace that defines it, and every one of them is live: an
17
+ * override is layered over its parent key by key, so a locale several workspaces define is several
18
+ * files. A workspace directory keeps one inherited file per identifier instead — one file cannot
19
+ * hold two names — so the parents' files are merged here, in the order the cloud resolves them.
20
+ *
21
+ * A single parent's file is kept verbatim, comments and nesting included. Only a locale that
22
+ * several parents define is rewritten, as a flat mapping of dotted keys, which is the shape the
23
+ * lookup uses anyway and the one shape where a nested and a dotted key cannot disagree.
24
+ */
25
+ function mergeInheritedLocales(locales) {
26
+ const inheritedByIdentifier = new Map();
27
+ for (const locale of locales.filter((locale) => locale.resolvingTeamSlug)) {
28
+ inheritedByIdentifier.set(locale.locale, [...(inheritedByIdentifier.get(locale.locale) ?? []), locale]);
29
+ }
30
+ return [
31
+ ...locales.filter((locale) => !locale.resolvingTeamSlug),
32
+ ...[...inheritedByIdentifier.values()].map((files) => (files.length === 1 ? files[0] : mergeLocaleFiles(files))),
33
+ ];
34
+ }
35
+ /**
36
+ * The platform walks parents sorted by their slug — `Team.parentTeamIdsInResolutionOrder` — and
37
+ * the first defining a key wins. Deriving that order here, rather than trusting the order the API
38
+ * happened to list the rows in (which follows the listing's `orderBy`, `updatedAt` by default),
39
+ * is what keeps a local run and the cloud resolving the same text.
40
+ */
41
+ function inResolutionOrder(files) {
42
+ return [...files].sort((a, b) => {
43
+ const [aSlug, bSlug] = [a.resolvingTeamSlug ?? '', b.resolvingTeamSlug ?? ''];
44
+ return aSlug < bSlug ? -1 : (aSlug > bSlug ? 1 : 0);
45
+ });
46
+ }
47
+ /**
48
+ * @param files the same locale as every parent workspace defines it, in any order
49
+ * @returns one locale carrying their merged translations, attributed to the winning parent
50
+ */
51
+ function mergeLocaleFiles(files) {
52
+ const ordered = inResolutionOrder(files);
53
+ const [winner] = ordered;
54
+ const translations = {};
55
+ // Lowest precedence first, so the first parent in resolution order is the text that survives —
56
+ // the same layering the platform applies.
57
+ for (const file of [...ordered].reverse()) {
58
+ Object.assign(translations, translationsOf(file));
59
+ }
60
+ return new types_1.Locale(winner.id, winner.locale, `${mergeHeader(ordered)}${(0, yaml_1.stringify)(translations)}`, winner.resolvingTeamSlug);
61
+ }
62
+ function translationsOf(file) {
63
+ try {
64
+ return (0, locale_resolver_1.flattenTranslations)((0, yaml_1.parse)(file.content));
65
+ }
66
+ catch (error) {
67
+ // Skipped rather than fatal: a workspace cannot fix a parent's file, and the cloud ignores an
68
+ // unparseable one too.
69
+ cli_1.default.log(color_1.default.yellow(`\n⚠️ locale '${file.locale}' from workspace '${file.resolvingTeamSlug}' could not be parsed and will be ignored: ${error.message}`));
70
+ return {};
71
+ }
72
+ }
73
+ /**
74
+ * Locale identifiers are case-sensitive on the platform (pt-BR and pt-br are different locales),
75
+ * but the filesystems macOS and Windows usually run on are not — both identifiers would collapse
76
+ * into one file, so a pull would write one locale over the other, `status` would report a phantom
77
+ * change forever, and the next push would silently overwrite one locale's cloud content with the
78
+ * other's. Refused outright before anything is written; on a case-sensitive filesystem the files
79
+ * live side by side and nothing is refused.
80
+ */
81
+ function assertLocaleFilesCanCoexist(locales, filesystemKeepsCase = filesystemKeepsCaseApart()) {
82
+ const slugsByFoldedSlug = new Map();
83
+ for (const locale of locales) {
84
+ const slug = locale.slug ?? locale.locale;
85
+ const folded = slug.toLowerCase();
86
+ slugsByFoldedSlug.set(folded, [...(slugsByFoldedSlug.get(folded) ?? []), slug]);
87
+ }
88
+ const collisions = [...slugsByFoldedSlug.values()].filter((slugs) => slugs.length > 1);
89
+ if (collisions.length === 0 || filesystemKeepsCase) {
90
+ return;
91
+ }
92
+ const described = collisions.map((slugs) => `'${slugs.join("' / '")}'`).join(', ');
93
+ throw new Error(`locales differing only in case cannot both be stored on this filesystem: ${described}. ` +
94
+ 'Rename or remove one of them in the cloud and pull again.');
95
+ }
96
+ function filesystemKeepsCaseApart() {
97
+ const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'fcode-case-'));
98
+ try {
99
+ fs.outputFileSync(path.join(probe, 'a'), '');
100
+ return !fs.existsSync(path.join(probe, 'A'));
101
+ }
102
+ finally {
103
+ fs.removeSync(probe);
104
+ }
105
+ }
106
+ function mergeHeader(files) {
107
+ const workspaces = files.map((file) => file.resolvingTeamSlug).join(', ');
108
+ return (`# Generated by fcode: the '${files[0].locale}' translations inherited from ${workspaces},\n` +
109
+ `# merged in the platform's resolution order (the first wins where they disagree).\n` +
110
+ `# Edit i18n/${files[0].locale}.yaml to override any of these keys.\n`);
111
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Builds the translations a local run resolves `fcode.i18n` against, so `fcode run` behaves like
3
+ * the cloud does.
4
+ *
5
+ * The cloud does this work in the engine: it merges the workspace's locale over its parents', then
6
+ * layers the chosen locale over the primary one and ships a single flat map. Locally there is no
7
+ * engine, so the same layering happens here over the files in `i18n/` — including the inherited
8
+ * ones, which `pull` writes into the same directory as `<locale>.inherited.yaml`.
9
+ */
10
+ export declare function flattenTranslations(document: unknown, prefix?: string, target?: Record<string, string>): Record<string, string>;
11
+ /**
12
+ * @param requestedLocale the locale to run with, or undefined to use the workspace's primary
13
+ * @returns the locale actually used and its flat translations, with the primary locale underneath
14
+ */
15
+ export declare function resolveLocalTranslations(requestedLocale?: string, workspaceDir?: string): {
16
+ locale?: string;
17
+ translations: Record<string, string>;
18
+ };
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.flattenTranslations = flattenTranslations;
4
+ exports.resolveLocalTranslations = resolveLocalTranslations;
5
+ const fs = require("fs-extra");
6
+ const path = require("node:path");
7
+ const yaml_1 = require("yaml");
8
+ const cli_1 = require("../utils/cli");
9
+ const color_1 = require("@oclif/color");
10
+ const types_1 = require("../core/types");
11
+ const I18N_DIRECTORY = 'i18n';
12
+ const YAML_EXTENSION = '.yaml';
13
+ const KEY_SEPARATOR = '.';
14
+ /**
15
+ * Builds the translations a local run resolves `fcode.i18n` against, so `fcode run` behaves like
16
+ * the cloud does.
17
+ *
18
+ * The cloud does this work in the engine: it merges the workspace's locale over its parents', then
19
+ * layers the chosen locale over the primary one and ships a single flat map. Locally there is no
20
+ * engine, so the same layering happens here over the files in `i18n/` — including the inherited
21
+ * ones, which `pull` writes into the same directory as `<locale>.inherited.yaml`.
22
+ */
23
+ function flattenTranslations(document, prefix = '', target = {}) {
24
+ if (document === null || document === undefined || typeof document !== 'object' || Array.isArray(document)) {
25
+ return target;
26
+ }
27
+ for (const [key, value] of Object.entries(document)) {
28
+ const keyPath = prefix ? `${prefix}${KEY_SEPARATOR}${key}` : key;
29
+ if (value === null || value === undefined) {
30
+ // A key written without a value is a placeholder: it counts as not translated, so lookups
31
+ // fall through to the fallback locale instead of resolving to an empty string.
32
+ continue;
33
+ }
34
+ if (typeof value === 'object' && !Array.isArray(value)) {
35
+ flattenTranslations(value, keyPath, target);
36
+ }
37
+ else if (!Array.isArray(value)) {
38
+ target[keyPath] = String(value);
39
+ }
40
+ }
41
+ return target;
42
+ }
43
+ function readLocaleFile(workspaceDir, slug) {
44
+ const filePath = path.join(workspaceDir, I18N_DIRECTORY, `${slug}${YAML_EXTENSION}`);
45
+ if (!fs.pathExistsSync(filePath)) {
46
+ return {};
47
+ }
48
+ try {
49
+ return flattenTranslations((0, yaml_1.parse)(fs.readFileSync(filePath).toString()));
50
+ }
51
+ catch (error) {
52
+ // A broken translation file must never stop a run, exactly as in the cloud.
53
+ cli_1.default.log(color_1.default.yellow(`\n⚠️ locale '${slug}' could not be parsed and will be ignored: ${error.message}`));
54
+ return {};
55
+ }
56
+ }
57
+ /** The identifier a locale file contributes keys to, whether the workspace owns the file or not. */
58
+ function identifierOf(slug) {
59
+ return slug.endsWith(types_1.Locale.INHERITED_SUFFIX) ? slug.slice(0, -types_1.Locale.INHERITED_SUFFIX.length) : slug;
60
+ }
61
+ /**
62
+ * One locale as this workspace resolves it: what it inherits, with its own file layered on top key
63
+ * by key, so a key it does not translate itself still resolves to the parent's text. `pull` has
64
+ * already merged the parents' files into the inherited one.
65
+ */
66
+ function readLocale(workspaceDir, locale) {
67
+ return {
68
+ ...readLocaleFile(workspaceDir, `${locale}${types_1.Locale.INHERITED_SUFFIX}`),
69
+ ...readLocaleFile(workspaceDir, locale),
70
+ };
71
+ }
72
+ function chosenPrimaryLocaleOf(workspaceDir) {
73
+ try {
74
+ const team = fs.readJsonSync(path.join(workspaceDir, 'team.json'));
75
+ return typeof team?.primaryLocale === 'string' && team.primaryLocale.length > 0 ? team.primaryLocale : undefined;
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ /** Every locale file in the workspace, own and inherited: `pull` writes them all to `i18n/`. */
82
+ function localeFileSlugs(workspaceDir) {
83
+ const directory = path.join(workspaceDir, I18N_DIRECTORY);
84
+ if (!fs.pathExistsSync(directory)) {
85
+ return [];
86
+ }
87
+ return fs
88
+ .readdirSync(directory)
89
+ .filter((fileName) => fileName.endsWith(YAML_EXTENSION))
90
+ .map((fileName) => path.basename(fileName, YAML_EXTENSION))
91
+ .sort();
92
+ }
93
+ /** Every locale the workspace resolves, counting a locale defined in several workspaces once. */
94
+ function availableLocales(workspaceDir) {
95
+ return [...new Set(localeFileSlugs(workspaceDir).map((slug) => identifierOf(slug)))].sort();
96
+ }
97
+ /**
98
+ * The workspace's primary locale: the one chosen in `team.json`, or its first locale when none is
99
+ * chosen — or when the chosen one has no file anymore, which would otherwise resolve zero keys.
100
+ * Mirrors the platform's `PrimaryLocale.resolve`, so a local run and the cloud agree on which
101
+ * locale a process without an explicit one resolves against.
102
+ */
103
+ function primaryLocaleOf(workspaceDir) {
104
+ const available = availableLocales(workspaceDir);
105
+ const chosen = chosenPrimaryLocaleOf(workspaceDir);
106
+ return chosen && available.includes(chosen) ? chosen : available[0];
107
+ }
108
+ /**
109
+ * @param requestedLocale the locale to run with, or undefined to use the workspace's primary
110
+ * @returns the locale actually used and its flat translations, with the primary locale underneath
111
+ */
112
+ function resolveLocalTranslations(requestedLocale, workspaceDir = process.cwd()) {
113
+ const primary = primaryLocaleOf(workspaceDir);
114
+ const locale = requestedLocale || primary;
115
+ if (!locale) {
116
+ return { locale: undefined, translations: {} };
117
+ }
118
+ const fallback = primary && primary !== locale ? readLocale(workspaceDir, primary) : {};
119
+ return { locale, translations: { ...fallback, ...readLocale(workspaceDir, locale) } };
120
+ }
@@ -10,6 +10,7 @@ export type RunProcessOptions = {
10
10
  variablesOverride?: Variable[];
11
11
  extraVariablesFile?: string;
12
12
  suppressElapsedLog?: boolean;
13
+ locale?: string;
13
14
  };
14
15
  export type RunHookOptions = {
15
16
  logLevel?: string;
@@ -34,7 +34,7 @@ function mergeVariables(base, ...overrides) {
34
34
  return [...map.values()];
35
35
  }
36
36
  async function runProcess(options) {
37
- const { slug, version, logLevel = 'DEBUG', request, jsonLogs = false, suppressElapsedLog = false } = options;
37
+ const { slug, version, logLevel = 'DEBUG', request, jsonLogs = false, suppressElapsedLog = false, locale } = options;
38
38
  const processRepository = new process_1.default();
39
39
  const variableRepository = new variable_1.default();
40
40
  const fcodeProcess = await processRepository.find(slug, version);
@@ -50,7 +50,7 @@ async function runProcess(options) {
50
50
  }
51
51
  }
52
52
  const ExecutionTask = fcodeProcess.script.programmingLanguage === 'PYTHON' ? python_execution_task_1.default : javascript_execution_task_1.default;
53
- const executor = new ExecutionTask(fcodeProcess.script.programmingLanguage, fcodeProcess, { version, parameters, variables, logLevel, request, jsonLogs });
53
+ const executor = new ExecutionTask(fcodeProcess.script.programmingLanguage, fcodeProcess, { version, parameters, variables, logLevel, request, jsonLogs, locale });
54
54
  const start = Date.now();
55
55
  try {
56
56
  await executor.prepare();
@@ -13,5 +13,7 @@ export declare function renderGitignoreWithInherited(existing: string, inherited
13
13
  * Refreshes the workspace .gitignore so inherited processes/modules (owned by a
14
14
  * parent team and pulled read-only) are not tracked by git. Regenerated from the
15
15
  * current workspace metadata, so resources that stop being inherited drop out.
16
+ * Inherited variable values live in one file, which is ignored as soon as the
17
+ * workspace inherits any variable; inherited locales are one file each, like modules.
16
18
  */
17
19
  export declare function syncInheritedGitignore(workspaceDir?: string): Promise<void>;
@@ -6,6 +6,8 @@ exports.syncInheritedGitignore = syncInheritedGitignore;
6
6
  const path = require("node:path");
7
7
  const fs = require("fs-extra");
8
8
  const workspace_1 = require("../config/workspace");
9
+ const global_1 = require("../config/global");
10
+ const settings_1 = require("../config/settings");
9
11
  const filenamify_1 = require("./filenamify");
10
12
  const file_utils_1 = require("./file-utils");
11
13
  exports.INHERITED_BLOCK_START = '# BEGIN fcode inherited resources (auto-generated, do not edit)';
@@ -43,9 +45,12 @@ function stripManagedBlock(content) {
43
45
  * Refreshes the workspace .gitignore so inherited processes/modules (owned by a
44
46
  * parent team and pulled read-only) are not tracked by git. Regenerated from the
45
47
  * current workspace metadata, so resources that stop being inherited drop out.
48
+ * Inherited variable values live in one file, which is ignored as soon as the
49
+ * workspace inherits any variable; inherited locales are one file each, like modules.
46
50
  */
47
51
  async function syncInheritedGitignore(workspaceDir = process.cwd()) {
48
52
  const team = await new workspace_1.default(workspaceDir).getActiveTeam();
53
+ const inheritedVariablesFileName = await new global_1.default().getSetting(settings_1.SettingKeys.INHERITED_VARIABLES_FILE_NAME);
49
54
  const inheritedPaths = [
50
55
  ...Object.values(team.processes)
51
56
  .filter((process) => process.resolvingTeamSlug)
@@ -53,6 +58,10 @@ async function syncInheritedGitignore(workspaceDir = process.cwd()) {
53
58
  ...Object.values(team.modules)
54
59
  .filter((module) => module.resolvingTeamSlug)
55
60
  .map((module) => `modules/${(0, filenamify_1.default)(module.name)}/`),
61
+ ...(Object.values(team.variables ?? {}).some((variable) => variable.resolvingTeamSlug) ? [inheritedVariablesFileName] : []),
62
+ ...Object.values(team.i18n ?? {})
63
+ .filter((locale) => locale.resolvingTeamSlug)
64
+ .map((locale) => `i18n/${(0, filenamify_1.default)(locale.slug)}.yaml`),
56
65
  ];
57
66
  const gitignorePath = path.join(workspaceDir, '.gitignore');
58
67
  const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';