@factorialco/fcode-cli 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +128 -2
  2. package/dist/commands/add.js +3 -1
  3. package/dist/commands/clone.d.ts +1 -0
  4. package/dist/commands/clone.js +19 -1
  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/status.js +4 -2
  23. package/dist/commands/team/push.js +3 -0
  24. package/dist/config/remote.js +1 -1
  25. package/dist/config/workspace.js +10 -0
  26. package/dist/core/commands/api-client-command.d.ts +1 -0
  27. package/dist/core/commands/api-client-command.js +5 -0
  28. package/dist/core/commands/base-command.d.ts +2 -0
  29. package/dist/core/commands/base-command.js +31 -0
  30. package/dist/core/commands/i18n-command.d.ts +5 -0
  31. package/dist/core/commands/i18n-command.js +16 -0
  32. package/dist/core/errors.d.ts +10 -0
  33. package/dist/core/errors.js +34 -1
  34. package/dist/core/executors/execution-task.js +4 -0
  35. package/dist/core/executors/javascript-execution-task.js +26 -1
  36. package/dist/core/resource-pusher.js +15 -0
  37. package/dist/core/types.d.ts +29 -1
  38. package/dist/core/types.js +46 -3
  39. package/dist/repositories/locale.d.ts +24 -0
  40. package/dist/repositories/locale.js +102 -0
  41. package/dist/repositories/resource.js +4 -1
  42. package/dist/repositories/team.js +5 -2
  43. package/dist/runtime/libs/python/fcode/__init__.py +1 -0
  44. package/dist/runtime/libs/python/fcode/context.py +43 -1
  45. package/dist/services/auth-service.d.ts +1 -0
  46. package/dist/services/auth-service.js +12 -0
  47. package/dist/services/fcode-graphql-client.d.ts +13 -1
  48. package/dist/services/fcode-graphql-client.js +136 -0
  49. package/dist/services/locale-merger.d.ts +23 -0
  50. package/dist/services/locale-merger.js +114 -0
  51. package/dist/services/locale-resolver.d.ts +18 -0
  52. package/dist/services/locale-resolver.js +120 -0
  53. package/dist/services/process-runner.d.ts +1 -0
  54. package/dist/services/process-runner.js +2 -2
  55. package/dist/services/team-access.d.ts +13 -0
  56. package/dist/services/team-access.js +45 -0
  57. package/dist/utils/gitignore.d.ts +1 -1
  58. package/dist/utils/gitignore.js +4 -1
  59. package/oclif.manifest.json +194 -1
  60. package/package.json +2 -1
@@ -8,6 +8,7 @@ declare const AuthService: {
8
8
  isAuthenticated(url: string): Promise<boolean>;
9
9
  isTokenExpiring(token: Token): boolean;
10
10
  refreshToken(authUrl: string, refreshToken: string): Promise<Token>;
11
+ refreshSession(url: string): Promise<boolean>;
11
12
  tryRefresh(url: string, { authUrl, token, userInfo }: AuthInfo): Promise<boolean>;
12
13
  login({ url, authUrl, email, password, clientId, clientSecret, accessToken }: Credentials): Promise<AuthInfo>;
13
14
  logout(url: string): Promise<void>;
@@ -117,6 +117,18 @@ const AuthService = {
117
117
  }
118
118
  throw token.error_description;
119
119
  },
120
+ // Force a re-mint of the access token regardless of expiry. Team groups are
121
+ // snapshotted into the token at issuance, so a token created before a team
122
+ // existed (or before the user joined it) 403s on it until re-minted (FCT-61231).
123
+ async refreshSession(url) {
124
+ try {
125
+ const authInfo = await this.globalConfig.getAuthInfo(url);
126
+ return await this.tryRefresh(url, authInfo);
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ },
120
132
  async tryRefresh(url, { authUrl, token, userInfo }) {
121
133
  if (!token.refresh_token) {
122
134
  return false;
@@ -1,5 +1,5 @@
1
1
  import { GraphQLClient } from 'graphql-request';
2
- import { Process, ProgrammingLanguage, ProgrammingLanguageManifest, RawTeamSettings, ScriptLibrary, TeamUpdateInput, TeamVersion, TeamVersionAlias, Variable, VersionedProcess, VersionedScriptLibrary, ServiceAccount } from '../core/types';
2
+ import { Locale, Process, ProgrammingLanguage, ProgrammingLanguageManifest, RawTeamSettings, ScriptLibrary, TeamUpdateInput, TeamVersion, TeamVersionAlias, Variable, VersionedProcess, VersionedScriptLibrary, ServiceAccount } from '../core/types';
3
3
  export type FcodeGraphQLClientOptions = {
4
4
  url: string;
5
5
  accessToken: string;
@@ -10,6 +10,7 @@ declare class FcodeGraphQLClient {
10
10
  private teamSlug;
11
11
  private url;
12
12
  constructor(options: FcodeGraphQLClientOptions);
13
+ setAccessToken(accessToken: string): void;
13
14
  isDuplicatedError(error: any): boolean;
14
15
  isEmptyValueError(error: any): boolean;
15
16
  cleanError(error: any): any;
@@ -22,6 +23,16 @@ declare class FcodeGraphQLClient {
22
23
  allVersionedScriptLibraries(scriptLibraryId: string): Promise<Array<VersionedScriptLibrary>>;
23
24
  createVersionedScriptLibrary(versionedScriptLibrary: VersionedScriptLibrary): Promise<void>;
24
25
  allVariables(): Promise<Array<Variable>>;
26
+ /**
27
+ * Every locale the workspace resolves: its own files, plus one merged file per identifier it
28
+ * inherits. A locale the workspace and a parent both define is listed twice by the platform and
29
+ * kept twice here — an override is layered over the parent's file key by key, so the parent's
30
+ * keys are still what the ones it does not mention resolve to.
31
+ */
32
+ allLocales(): Promise<Array<Locale>>;
33
+ pushLocale(locale: Locale): Promise<Locale>;
34
+ updateLocale(locale: Locale): Promise<Locale>;
35
+ removeLocale(locale: string | undefined): Promise<void>;
25
36
  private toProcessInput;
26
37
  pushProcess(process: Process): Promise<Process>;
27
38
  removeProcess(id: String | undefined): Promise<Process>;
@@ -53,6 +64,7 @@ declare class FcodeGraphQLClient {
53
64
  /** The API upserts: an existing alias with the same name is re-pointed to the tag. */
54
65
  createTeamVersionAlias(name: string, tag: string): Promise<TeamVersionAlias>;
55
66
  deleteTeamVersionAlias(id: string): Promise<TeamVersionAlias>;
67
+ verifyTeamAccess(): Promise<void>;
56
68
  team(): Promise<RawTeamSettings>;
57
69
  pushTeam(update: TeamUpdateInput): Promise<RawTeamSettings>;
58
70
  mapProgrammingLanguageManifest(programmingLanguageManifest: any): ProgrammingLanguageManifest;
@@ -2,6 +2,7 @@
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");
6
7
  // Shared team version selections so every query/mutation returns the same shape.
7
8
  const TEAM_VERSION_FIELDS = `
@@ -89,6 +90,11 @@ class FcodeGraphQLClient {
89
90
  },
90
91
  });
91
92
  }
93
+ // Swap the bearer token on the live client (after a session refresh) so every
94
+ // holder of this instance — repositories, pushers, queries — uses the new one.
95
+ setAccessToken(accessToken) {
96
+ this.client.setHeader('authorization', `Bearer ${accessToken}`);
97
+ }
92
98
  isDuplicatedError(error) {
93
99
  return (error &&
94
100
  error.response &&
@@ -302,6 +308,114 @@ class FcodeGraphQLClient {
302
308
  }
303
309
  return variables;
304
310
  }
311
+ /**
312
+ * Every locale the workspace resolves: its own files, plus one merged file per identifier it
313
+ * inherits. A locale the workspace and a parent both define is listed twice by the platform and
314
+ * kept twice here — an override is layered over the parent's file key by key, so the parent's
315
+ * keys are still what the ones it does not mention resolve to.
316
+ */
317
+ async allLocales() {
318
+ const pageSize = 200;
319
+ const locales = [];
320
+ let after = null;
321
+ try {
322
+ do {
323
+ const { locales: { edges, pageInfo }, } = await this.client.request((0, graphql_request_1.gql) `
324
+ query locales($first: Int!, $after: String) {
325
+ locales(first: $first, after: $after) {
326
+ edges {
327
+ node {
328
+ id
329
+ locale
330
+ content
331
+ resolvingTeamSlug
332
+ }
333
+ }
334
+ pageInfo {
335
+ hasNextPage
336
+ endCursor
337
+ }
338
+ }
339
+ }
340
+ `, {
341
+ after,
342
+ first: pageSize,
343
+ });
344
+ for (const { node: { id, locale, content, resolvingTeamSlug }, } of edges) {
345
+ locales.push(new types_1.Locale(id, locale, content, this.inheritedTeam(resolvingTeamSlug)));
346
+ }
347
+ after = pageInfo?.hasNextPage ? pageInfo.endCursor : null;
348
+ } while (after);
349
+ }
350
+ catch (error) {
351
+ return Promise.reject(this.cleanError(error));
352
+ }
353
+ // The cloud team's parent order is the inheritance precedence the merge must replicate; the
354
+ // local team.json may lag behind it, so the cloud is asked, not the file.
355
+ const { parentTeamSlugs } = await this.team();
356
+ const merged = (0, locale_merger_1.mergeInheritedLocales)(locales, parentTeamSlugs ?? []);
357
+ (0, locale_merger_1.assertLocaleFilesCanCoexist)(merged);
358
+ return merged;
359
+ }
360
+ pushLocale(locale) {
361
+ return this.client
362
+ .request((0, graphql_request_1.gql) `
363
+ mutation createLocale($locale: String!, $content: String!) {
364
+ createLocale(locale: $locale, content: $content) {
365
+ id
366
+ locale
367
+ content
368
+ }
369
+ }
370
+ `, {
371
+ locale: locale.locale,
372
+ content: locale.content,
373
+ })
374
+ .then(({ createLocale: created }) => new types_1.Locale(created.id, created.locale, created.content))
375
+ .catch((error) => {
376
+ return Promise.reject(this.cleanError(error));
377
+ });
378
+ }
379
+ updateLocale(locale) {
380
+ return this.client
381
+ .request((0, graphql_request_1.gql) `
382
+ mutation updateLocale($locale: String!, $content: String!) {
383
+ updateLocale(locale: $locale, content: $content) {
384
+ id
385
+ locale
386
+ content
387
+ }
388
+ }
389
+ `, {
390
+ locale: locale.locale,
391
+ content: locale.content,
392
+ })
393
+ .then(({ updateLocale: updated }) => new types_1.Locale(updated.id, updated.locale, updated.content))
394
+ .catch((error) => {
395
+ return Promise.reject(this.cleanError(error));
396
+ });
397
+ }
398
+ // Addressed by identifier rather than id: a locale is its file, and the delete path is reached
399
+ // from a file that is gone, whose recorded id may predate an override.
400
+ removeLocale(locale) {
401
+ if (!locale) {
402
+ return Promise.reject(new Error('locale is required during remove'));
403
+ }
404
+ return this.client
405
+ .request((0, graphql_request_1.gql) `
406
+ mutation deleteLocale($locale: String!) {
407
+ deleteLocale(locale: $locale) {
408
+ id
409
+ }
410
+ }
411
+ `, {
412
+ locale,
413
+ })
414
+ .then(() => undefined)
415
+ .catch((error) => {
416
+ return Promise.reject(this.cleanError(error));
417
+ });
418
+ }
305
419
  toProcessInput(process, includeCreateOnly) {
306
420
  const settings = {
307
421
  dependencies: { scopedToProcess: process.settings.dependencies.scopedToProcess },
@@ -639,6 +753,9 @@ class FcodeGraphQLClient {
639
753
  settings.webhookAuth.headerName = webhookAuth.headerName;
640
754
  }
641
755
  }
756
+ if (team.settings?.primaryLocale) {
757
+ settings.primaryLocale = team.settings.primaryLocale;
758
+ }
642
759
  return settings;
643
760
  }
644
761
  mapTeamVersion(teamVersion) {
@@ -751,6 +868,23 @@ class FcodeGraphQLClient {
751
868
  return Promise.reject(this.cleanError(error));
752
869
  });
753
870
  }
871
+ // Cheapest team-scoped query, used as a pre-flight access check so commands can
872
+ // fail once with a clear error before touching local state, instead of erroring
873
+ // on every resource pull.
874
+ verifyTeamAccess() {
875
+ return this.client
876
+ .request((0, graphql_request_1.gql) `
877
+ query team($slug: ID!) {
878
+ team(slug: $slug) {
879
+ slug
880
+ }
881
+ }
882
+ `, { slug: this.teamSlug })
883
+ .then(() => undefined)
884
+ .catch((error) => {
885
+ return Promise.reject(this.cleanError(error));
886
+ });
887
+ }
754
888
  team() {
755
889
  return this.client
756
890
  .request((0, graphql_request_1.gql) `
@@ -768,6 +902,7 @@ class FcodeGraphQLClient {
768
902
  headerName
769
903
  variableKey
770
904
  }
905
+ primaryLocale
771
906
  }
772
907
  }
773
908
  }
@@ -794,6 +929,7 @@ class FcodeGraphQLClient {
794
929
  headerName
795
930
  variableKey
796
931
  }
932
+ primaryLocale
797
933
  }
798
934
  }
799
935
  }
@@ -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>, parentTeamSlugs: Array<string>): 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,114 @@
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, parentTeamSlugs) {
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, parentTeamSlugs)),
33
+ ];
34
+ }
35
+ /**
36
+ * The platform walks parents in the team's configured order — `Team.parentTeamIdsInResolutionOrder`
37
+ * — and the first defining a key wins. Deriving that order here from the cloud team's
38
+ * `parentTeamSlugs`, rather than trusting the order the API happened to list the rows in (which
39
+ * follows the listing's `orderBy`, `updatedAt` by default), is what keeps a local run and the
40
+ * cloud resolving the same text.
41
+ */
42
+ function inResolutionOrder(files, parentTeamSlugs) {
43
+ const precedence = (file) => {
44
+ const index = parentTeamSlugs.indexOf(file.resolvingTeamSlug ?? '');
45
+ return index === -1 ? parentTeamSlugs.length : index;
46
+ };
47
+ return [...files].sort((a, b) => precedence(a) - precedence(b));
48
+ }
49
+ /**
50
+ * @param files the same locale as every parent workspace defines it, in any order
51
+ * @param parentTeamSlugs the workspace's parents in inheritance-precedence order
52
+ * @returns one locale carrying their merged translations, attributed to the winning parent
53
+ */
54
+ function mergeLocaleFiles(files, parentTeamSlugs) {
55
+ const ordered = inResolutionOrder(files, parentTeamSlugs);
56
+ const [winner] = ordered;
57
+ const translations = {};
58
+ // Lowest precedence first, so the first parent in resolution order is the text that survives —
59
+ // the same layering the platform applies.
60
+ for (const file of [...ordered].reverse()) {
61
+ Object.assign(translations, translationsOf(file));
62
+ }
63
+ return new types_1.Locale(winner.id, winner.locale, `${mergeHeader(ordered)}${(0, yaml_1.stringify)(translations)}`, winner.resolvingTeamSlug);
64
+ }
65
+ function translationsOf(file) {
66
+ try {
67
+ return (0, locale_resolver_1.flattenTranslations)((0, yaml_1.parse)(file.content));
68
+ }
69
+ catch (error) {
70
+ // Skipped rather than fatal: a workspace cannot fix a parent's file, and the cloud ignores an
71
+ // unparseable one too.
72
+ 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}`));
73
+ return {};
74
+ }
75
+ }
76
+ /**
77
+ * Locale identifiers are case-sensitive on the platform (pt-BR and pt-br are different locales),
78
+ * but the filesystems macOS and Windows usually run on are not — both identifiers would collapse
79
+ * into one file, so a pull would write one locale over the other, `status` would report a phantom
80
+ * change forever, and the next push would silently overwrite one locale's cloud content with the
81
+ * other's. Refused outright before anything is written; on a case-sensitive filesystem the files
82
+ * live side by side and nothing is refused.
83
+ */
84
+ function assertLocaleFilesCanCoexist(locales, filesystemKeepsCase = filesystemKeepsCaseApart()) {
85
+ const slugsByFoldedSlug = new Map();
86
+ for (const locale of locales) {
87
+ const slug = locale.slug ?? locale.locale;
88
+ const folded = slug.toLowerCase();
89
+ slugsByFoldedSlug.set(folded, [...(slugsByFoldedSlug.get(folded) ?? []), slug]);
90
+ }
91
+ const collisions = [...slugsByFoldedSlug.values()].filter((slugs) => slugs.length > 1);
92
+ if (collisions.length === 0 || filesystemKeepsCase) {
93
+ return;
94
+ }
95
+ const described = collisions.map((slugs) => `'${slugs.join("' / '")}'`).join(', ');
96
+ throw new Error(`locales differing only in case cannot both be stored on this filesystem: ${described}. ` +
97
+ 'Rename or remove one of them in the cloud and pull again.');
98
+ }
99
+ function filesystemKeepsCaseApart() {
100
+ const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'fcode-case-'));
101
+ try {
102
+ fs.outputFileSync(path.join(probe, 'a'), '');
103
+ return !fs.existsSync(path.join(probe, 'A'));
104
+ }
105
+ finally {
106
+ fs.removeSync(probe);
107
+ }
108
+ }
109
+ function mergeHeader(files) {
110
+ const workspaces = files.map((file) => file.resolvingTeamSlug).join(', ');
111
+ return (`# Generated by fcode: the '${files[0].locale}' translations inherited from ${workspaces},\n` +
112
+ `# merged in the platform's resolution order (the first wins where they disagree).\n` +
113
+ `# Edit i18n/${files[0].locale}.yaml to override any of these keys.\n`);
114
+ }
@@ -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();
@@ -0,0 +1,13 @@
1
+ import FcodeGraphQLClient from './fcode-graphql-client';
2
+ import GlobalConfig from '../config/global';
3
+ declare const TeamAccessService: {
4
+ globalConfig: GlobalConfig;
5
+ clientFactory: (options: {
6
+ url: string;
7
+ accessToken: string;
8
+ teamSlug: string;
9
+ }) => FcodeGraphQLClient;
10
+ ensureTeamAccess(url: string, slug: string): Promise<void>;
11
+ checkAccess(url: string, slug: string): Promise<void>;
12
+ };
13
+ export default TeamAccessService;
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const auth_service_1 = require("./auth-service");
4
+ const fcode_graphql_client_1 = require("./fcode-graphql-client");
5
+ const global_1 = require("../config/global");
6
+ const errors_1 = require("../core/errors");
7
+ function is403(error) {
8
+ return error?.response?.status === 403;
9
+ }
10
+ const TeamAccessService = {
11
+ globalConfig: new global_1.default(),
12
+ clientFactory: (options) => new fcode_graphql_client_1.default(options),
13
+ // Pre-flight check that the current session can access the team, run before any
14
+ // local state is touched so a denied session fails once with a clear error
15
+ // instead of erroring on every resource pull. A 403 may just mean the token
16
+ // predates the team: groups are snapshotted into the token at issuance
17
+ // (FCT-61231), so re-mint it through the refresh grant and retry once.
18
+ async ensureTeamAccess(url, slug) {
19
+ try {
20
+ await this.checkAccess(url, slug);
21
+ }
22
+ catch (error) {
23
+ if (!is403(error)) {
24
+ throw error;
25
+ }
26
+ if (!(await auth_service_1.default.refreshSession(url))) {
27
+ throw new errors_1.TeamAccessDenied(slug);
28
+ }
29
+ try {
30
+ await this.checkAccess(url, slug);
31
+ }
32
+ catch (retryError) {
33
+ if (is403(retryError)) {
34
+ throw new errors_1.TeamAccessDenied(slug);
35
+ }
36
+ throw retryError;
37
+ }
38
+ }
39
+ },
40
+ async checkAccess(url, slug) {
41
+ const { token } = await this.globalConfig.getAuthInfo(url);
42
+ await this.clientFactory({ url, accessToken: token.access_token, teamSlug: slug }).verifyTeamAccess();
43
+ },
44
+ };
45
+ exports.default = TeamAccessService;
@@ -14,6 +14,6 @@ export declare function renderGitignoreWithInherited(existing: string, inherited
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
16
  * Inherited variable values live in one file, which is ignored as soon as the
17
- * workspace inherits any variable.
17
+ * workspace inherits any variable; inherited locales are one file each, like modules.
18
18
  */
19
19
  export declare function syncInheritedGitignore(workspaceDir?: string): Promise<void>;
@@ -46,7 +46,7 @@ function stripManagedBlock(content) {
46
46
  * parent team and pulled read-only) are not tracked by git. Regenerated from the
47
47
  * current workspace metadata, so resources that stop being inherited drop out.
48
48
  * Inherited variable values live in one file, which is ignored as soon as the
49
- * workspace inherits any variable.
49
+ * workspace inherits any variable; inherited locales are one file each, like modules.
50
50
  */
51
51
  async function syncInheritedGitignore(workspaceDir = process.cwd()) {
52
52
  const team = await new workspace_1.default(workspaceDir).getActiveTeam();
@@ -59,6 +59,9 @@ async function syncInheritedGitignore(workspaceDir = process.cwd()) {
59
59
  .filter((module) => module.resolvingTeamSlug)
60
60
  .map((module) => `modules/${(0, filenamify_1.default)(module.name)}/`),
61
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`),
62
65
  ];
63
66
  const gitignorePath = path.join(workspaceDir, '.gitignore');
64
67
  const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';