@factorialco/fcode-cli 2.1.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 (52) 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 +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/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/i18n-command.d.ts +5 -0
  27. package/dist/core/commands/i18n-command.js +16 -0
  28. package/dist/core/errors.d.ts +6 -0
  29. package/dist/core/errors.js +19 -1
  30. package/dist/core/executors/execution-task.js +4 -0
  31. package/dist/core/executors/javascript-execution-task.js +26 -1
  32. package/dist/core/resource-pusher.js +15 -0
  33. package/dist/core/types.d.ts +29 -1
  34. package/dist/core/types.js +43 -2
  35. package/dist/repositories/locale.d.ts +24 -0
  36. package/dist/repositories/locale.js +102 -0
  37. package/dist/repositories/resource.js +4 -1
  38. package/dist/repositories/team.js +5 -2
  39. package/dist/runtime/libs/python/fcode/__init__.py +1 -0
  40. package/dist/runtime/libs/python/fcode/context.py +43 -1
  41. package/dist/services/fcode-graphql-client.d.ts +11 -1
  42. package/dist/services/fcode-graphql-client.js +111 -0
  43. package/dist/services/locale-merger.d.ts +23 -0
  44. package/dist/services/locale-merger.js +111 -0
  45. package/dist/services/locale-resolver.d.ts +18 -0
  46. package/dist/services/locale-resolver.js +120 -0
  47. package/dist/services/process-runner.d.ts +1 -0
  48. package/dist/services/process-runner.js +2 -2
  49. package/dist/utils/gitignore.d.ts +1 -1
  50. package/dist/utils/gitignore.js +4 -1
  51. package/oclif.manifest.json +194 -1
  52. package/package.json +2 -1
@@ -4,6 +4,7 @@ const node_child_process_1 = require("node:child_process");
4
4
  const fs = require("node:fs");
5
5
  const os = require("node:os");
6
6
  const path = require("node:path");
7
+ const locale_resolver_1 = require("../../services/locale-resolver");
7
8
  const errors_1 = require("../errors");
8
9
  const cli_1 = require("../../utils/cli");
9
10
  const dependencies_1 = require("../utils/dependencies");
@@ -114,6 +115,9 @@ class ExecutionTask {
114
115
  },
115
116
  variables: this.getVariablesObject(),
116
117
  request: this._options.request ?? 'No request, you are using the CLI',
118
+ // Layered here rather than in the generated runtime: the cloud ships one already-merged
119
+ // flat map, and a local run has to resolve fcode.i18n against the same shape.
120
+ i18n: (0, locale_resolver_1.resolveLocalTranslations)(this._options.locale),
117
121
  };
118
122
  }
119
123
  async prepare() {
@@ -111,7 +111,12 @@ const main = typeof _hooksModule['${fn}'] === 'function'
111
111
  };`}
112
112
 
113
113
  global.fcode = {
114
- import: (moduleName, version) => {
114
+ // A version is either a bare string or an options object ({ version: 'v1' }), the
115
+ // form every other versioned helper takes — the cloud reads both, so a local run must too.
116
+ import: (moduleName, tagOrOptions) => {
117
+ const version = typeof tagOrOptions === 'string'
118
+ ? tagOrOptions
119
+ : (tagOrOptions && typeof tagOrOptions === 'object' ? tagOrOptions.version : undefined);
115
120
  let modulePath = require('path').join('${process.cwd()}', 'modules', moduleName);
116
121
  if(version) {
117
122
  modulePath = require('path').join(modulePath, 'versions', version)
@@ -588,6 +593,26 @@ const main = typeof _hooksModule['${fn}'] === 'function'
588
593
  }
589
594
  };
590
595
  })(),
596
+ // The third argument is the version options the cloud honours. A workspace directory holds
597
+ // no published snapshots, so a pinned call resolves against the files on disk — the same
598
+ // per-file fallback the cloud applies to a locale that was never published at that tag.
599
+ // Own properties only and no array arguments, exactly like the cloud executors.
600
+ i18n: Object.assign((key, args, _options) => {
601
+ const translations = ${JSON.stringify(metadata.i18n.translations, null, 2)};
602
+ const template = Object.hasOwn(translations, key) ? translations[key] : undefined;
603
+ if (template === undefined) {
604
+ return key;
605
+ }
606
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
607
+ return template;
608
+ }
609
+ // Single pass, so a substituted value containing %{...} is never rescanned.
610
+ return template.replace(/%\{([^}]*)\}/g, (match, name) => {
611
+ const trimmed = name.trim();
612
+ const value = Object.hasOwn(args, trimmed) ? args[trimmed] : undefined;
613
+ return value === undefined || value === null ? match : String(value);
614
+ });
615
+ }, { locale: ${JSON.stringify(metadata.i18n.locale ?? null)} }),
591
616
  sendMail: async ({ to, subject, text, html } = {}) => {
592
617
  // No manager locally: log the email contents instead of sending, so
593
618
  // devs can see exactly what would be delivered when run in the cloud.
@@ -82,6 +82,16 @@ class ResourcePusher {
82
82
  const createdVariable = await this._fcodeGraphQLClient.pushVariable(variable);
83
83
  return this._repository.save(createdVariable);
84
84
  }
85
+ if (this._repository._repositoryName === 'i18n') {
86
+ const locale = resource;
87
+ // The recorded id is only a hint: pushing an identifier a parent workspace owns has to
88
+ // create an override here, so the update path is taken only for a locale this workspace
89
+ // already owns.
90
+ const pushed = locale.id && !locale.resolvingTeamSlug
91
+ ? await this._fcodeGraphQLClient.updateLocale(locale)
92
+ : await this._fcodeGraphQLClient.pushLocale(locale);
93
+ return this._repository.save(pushed);
94
+ }
85
95
  if (this._repository._repositoryName === 'dependencies') {
86
96
  const updatedProgrammingLanguageManifests = await this._fcodeGraphQLClient.pushProgrammingLanguageManifest(resource);
87
97
  return this._repository.save(updatedProgrammingLanguageManifests);
@@ -114,6 +124,11 @@ class ResourcePusher {
114
124
  if (this._repository._repositoryName === 'variables') {
115
125
  await this._fcodeGraphQLClient.removeVariable(resource.id);
116
126
  }
127
+ if (this._repository._repositoryName === 'i18n') {
128
+ // By identifier, not slug: an inherited locale is tracked under a qualified slug, and only
129
+ // this workspace's own locales — where the two coincide — ever reach a remote delete.
130
+ await this._fcodeGraphQLClient.removeLocale(resource.locale);
131
+ }
117
132
  this._repository.removeMetadata(resource.slug);
118
133
  }
119
134
  catch (error) {
@@ -41,6 +41,9 @@ export interface TeamMetadata {
41
41
  variables: {
42
42
  [key: string]: Variable;
43
43
  };
44
+ i18n: {
45
+ [key: string]: Locale;
46
+ };
44
47
  dependencies: {
45
48
  [key: string]: ProgrammingLanguageManifest;
46
49
  };
@@ -187,6 +190,27 @@ export declare class ScriptLibrary extends Resource {
187
190
  constructor(id: string, name: string, sourceCode: string, programmingLanguage: ProgrammingLanguage, resolvingTeamSlug?: string);
188
191
  get contentHash(): string;
189
192
  }
193
+ export declare class Locale extends Resource {
194
+ static LOCALE_REGEX: RegExp;
195
+ static INHERITED_SUFFIX: string;
196
+ id: string | undefined;
197
+ slug: string;
198
+ locale: string;
199
+ content: string;
200
+ constructor(id: string | undefined, locale: string, content: string, resolvingTeamSlug?: string);
201
+ /**
202
+ * The name a locale is tracked and stored under. Unlike every other resource, the identifier is
203
+ * not unique: a workspace sees its own `en` and each parent workspace's `en`, and all of them are
204
+ * live, because an override is layered over the parent's file key by key rather than replacing
205
+ * it. Naming files after the identifier alone would keep only one of them.
206
+ *
207
+ * A workspace directory therefore holds two files at most per identifier: `en.yaml` for its own
208
+ * and `en.inherited.yaml` for everything it inherits, which `mergeInheritedLocales` collapses
209
+ * into one file. An identifier cannot contain a dot, so the name parses back.
210
+ */
211
+ static slugFor(locale: string, resolvingTeamSlug?: string): string;
212
+ get contentHash(): string;
213
+ }
190
214
  export declare class VersionedScriptLibrary extends Resource {
191
215
  id: string;
192
216
  slug: string;
@@ -262,9 +286,10 @@ export declare class Team extends Resource {
262
286
  errorHandlerConfig?: ErrorHandlerConfig;
263
287
  parentTeamSlugs: string[];
264
288
  webhookAuth?: WebhookAuth;
289
+ primaryLocale?: string;
265
290
  versions?: TeamVersionFileEntry[];
266
291
  aliases?: TeamAliasFileEntry[];
267
- constructor(slug: string, parentTeamSlugs: string[], zoneId?: string, errorHandlerConfig?: ErrorHandlerConfig, webhookAuth?: WebhookAuth);
292
+ constructor(slug: string, parentTeamSlugs: string[], zoneId?: string, errorHandlerConfig?: ErrorHandlerConfig, webhookAuth?: WebhookAuth, primaryLocale?: string);
268
293
  get contentHash(): string;
269
294
  }
270
295
  export interface RawErrorHandlerConfig {
@@ -277,6 +302,7 @@ export interface RawTeamSettings {
277
302
  errorHandlerConfig?: RawErrorHandlerConfig | null;
278
303
  parentTeamSlugs: string[];
279
304
  webhookAuth?: WebhookAuth | null;
305
+ primaryLocale?: string | null;
280
306
  }
281
307
  export interface TeamUpdateInput {
282
308
  zoneId?: string;
@@ -286,6 +312,7 @@ export interface TeamUpdateInput {
286
312
  headerName?: string | null;
287
313
  variableKey?: string | null;
288
314
  } | null;
315
+ primaryLocale?: string;
289
316
  }
290
317
  export type ResourceMetadata = {
291
318
  [key: string]: any;
@@ -317,6 +344,7 @@ export type ExecutionOptions = {
317
344
  hookFilePath?: string;
318
345
  hookFunction?: string;
319
346
  silent?: boolean;
347
+ locale?: string;
320
348
  };
321
349
  export declare const ForceFlag: {
322
350
  force: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ServiceAccount = exports.Dependency = exports.DependenciesFlags = exports.TeamNameArg = exports.ResourceSlugArg = exports.ForceFlag = exports.ScriptLibraryNotFound = exports.ProcessNotFound = exports.Team = exports.ProgrammingLanguageManifest = exports.Variable = exports.VersionedScriptLibrary = exports.ScriptLibrary = exports.VersionedProcess = exports.Process = exports.DEFAULT_WEBHOOK_AUTH_HEADER = exports.programmingLanguageDisplayName = exports.ResourceStatus = exports.OnConflictResolution = exports.Resource = void 0;
3
+ exports.ServiceAccount = exports.Dependency = exports.DependenciesFlags = exports.TeamNameArg = exports.ResourceSlugArg = exports.ForceFlag = exports.ScriptLibraryNotFound = exports.ProcessNotFound = exports.Team = exports.ProgrammingLanguageManifest = exports.Variable = exports.VersionedScriptLibrary = exports.Locale = exports.ScriptLibrary = exports.VersionedProcess = exports.Process = exports.DEFAULT_WEBHOOK_AUTH_HEADER = exports.programmingLanguageDisplayName = exports.ResourceStatus = exports.OnConflictResolution = exports.Resource = void 0;
4
4
  const core_1 = require("@oclif/core");
5
5
  const crypto = require("node:crypto");
6
6
  const canonical_hash_1 = require("../utils/canonical-hash");
@@ -103,6 +103,41 @@ class ScriptLibrary extends Resource {
103
103
  }
104
104
  }
105
105
  exports.ScriptLibrary = ScriptLibrary;
106
+ class Locale extends Resource {
107
+ constructor(id, locale, content, resolvingTeamSlug) {
108
+ super();
109
+ this.id = id;
110
+ this.slug = Locale.slugFor(locale, resolvingTeamSlug);
111
+ this.locale = locale;
112
+ this.content = content;
113
+ this.resolvingTeamSlug = resolvingTeamSlug;
114
+ }
115
+ /**
116
+ * The name a locale is tracked and stored under. Unlike every other resource, the identifier is
117
+ * not unique: a workspace sees its own `en` and each parent workspace's `en`, and all of them are
118
+ * live, because an override is layered over the parent's file key by key rather than replacing
119
+ * it. Naming files after the identifier alone would keep only one of them.
120
+ *
121
+ * A workspace directory therefore holds two files at most per identifier: `en.yaml` for its own
122
+ * and `en.inherited.yaml` for everything it inherits, which `mergeInheritedLocales` collapses
123
+ * into one file. An identifier cannot contain a dot, so the name parses back.
124
+ */
125
+ static slugFor(locale, resolvingTeamSlug) {
126
+ return resolvingTeamSlug ? `${locale}${Locale.INHERITED_SUFFIX}` : locale;
127
+ }
128
+ // Hashed over the raw YAML: the file is the source of truth and round-trips comments and
129
+ // ordering, so any edit — even one that parses to the same translations — is a change to push.
130
+ get contentHash() {
131
+ return crypto.createHash('md5').update(this.content).digest('hex');
132
+ }
133
+ }
134
+ exports.Locale = Locale;
135
+ // Free-form identifier rather than a validated BCP-47 tag, so a workspace can use whatever it
136
+ // already names its translations after. Case is significant: pt-BR and pt-br are different
137
+ // locales, and the platform's unique index is case-sensitive too.
138
+ Locale.LOCALE_REGEX = /^[A-Za-z0-9][\w-]{0,19}$/;
139
+ // Marks a file as owned by a parent workspace, the way `variables.inherited.env` does.
140
+ Locale.INHERITED_SUFFIX = '.inherited';
106
141
  class VersionedScriptLibrary extends Resource {
107
142
  constructor(id, tag, sourceCode, programmingLanguage) {
108
143
  super();
@@ -166,13 +201,14 @@ class ProgrammingLanguageManifest extends Resource {
166
201
  }
167
202
  exports.ProgrammingLanguageManifest = ProgrammingLanguageManifest;
168
203
  class Team extends Resource {
169
- constructor(slug, parentTeamSlugs, zoneId, errorHandlerConfig, webhookAuth) {
204
+ constructor(slug, parentTeamSlugs, zoneId, errorHandlerConfig, webhookAuth, primaryLocale) {
170
205
  super();
171
206
  this.slug = slug;
172
207
  this.parentTeamSlugs = parentTeamSlugs;
173
208
  this.zoneId = zoneId;
174
209
  this.errorHandlerConfig = errorHandlerConfig;
175
210
  this.webhookAuth = webhookAuth;
211
+ this.primaryLocale = primaryLocale;
176
212
  }
177
213
  get contentHash() {
178
214
  const payload = {
@@ -186,6 +222,11 @@ class Team extends Resource {
186
222
  if (this.webhookAuth) {
187
223
  payload.webhookAuth = this.webhookAuth;
188
224
  }
225
+ // Same insert-only-when-set rule, for the same reason: a team.json written before locales
226
+ // existed must keep the hash already stored in .fcode.
227
+ if (this.primaryLocale) {
228
+ payload.primaryLocale = this.primaryLocale;
229
+ }
189
230
  return (0, canonical_hash_1.canonicalJsonHash)(payload);
190
231
  }
191
232
  }
@@ -0,0 +1,24 @@
1
+ import { Locale, ResourceMetadata } from '../core/types';
2
+ import ResourceRepositoryBase from './resource';
3
+ /**
4
+ * Locales live as one YAML file per locale directly under `i18n/`, named after the slug
5
+ * (`i18n/pt-BR.yaml`, `i18n/pt-BR.inherited.yaml`). A file, not a directory: a locale has exactly
6
+ * one artifact, and naming the file after the identifier is what lets `push` work without knowing
7
+ * any ids.
8
+ *
9
+ * A workspace keeps what it inherits next to what it owns, because overriding a locale only layers
10
+ * keys over the inherited file rather than replacing it — see {@link Locale.slugFor}.
11
+ */
12
+ declare class LocaleRepository extends ResourceRepositoryBase<Locale> {
13
+ constructor();
14
+ find(slug: string): Promise<Locale>;
15
+ findSlugsNotAdded(): Promise<Array<string>>;
16
+ protected getResourcePath(locale: Locale): string;
17
+ protected removeFiles(locale: Locale): Promise<void>;
18
+ protected resourceToMetadata(locale: Locale): ResourceMetadata;
19
+ protected saveFiles(locale: Locale): Promise<void>;
20
+ metadataFromFiles(slug: string): Promise<ResourceMetadata>;
21
+ validSlug(slug: string): boolean;
22
+ slugFormatMessage(): string;
23
+ }
24
+ export default LocaleRepository;
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const fs = require("fs-extra");
4
+ const path = require("node:path");
5
+ const color_1 = require("@oclif/color");
6
+ const cli_1 = require("../utils/cli");
7
+ const errors_1 = require("../core/errors");
8
+ const types_1 = require("../core/types");
9
+ const file_utils_1 = require("../utils/file-utils");
10
+ const resource_1 = require("./resource");
11
+ const YAML_EXTENSION = '.yaml';
12
+ /**
13
+ * Locales live as one YAML file per locale directly under `i18n/`, named after the slug
14
+ * (`i18n/pt-BR.yaml`, `i18n/pt-BR.inherited.yaml`). A file, not a directory: a locale has exactly
15
+ * one artifact, and naming the file after the identifier is what lets `push` work without knowing
16
+ * any ids.
17
+ *
18
+ * A workspace keeps what it inherits next to what it owns, because overriding a locale only layers
19
+ * keys over the inherited file rather than replacing it — see {@link Locale.slugFor}.
20
+ */
21
+ class LocaleRepository extends resource_1.default {
22
+ constructor() {
23
+ super({
24
+ name: 'i18n',
25
+ });
26
+ }
27
+ async find(slug) {
28
+ const { [this._repositoryName]: locales } = await this.workspaceConfig.getActiveTeam();
29
+ const localeMetadata = locales?.[slug];
30
+ if (!localeMetadata) {
31
+ throw new errors_1.LocalLocaleNotFound(new Error('Locale not found on team metadata'), slug);
32
+ }
33
+ try {
34
+ const content = fs.readFileSync(this.getResourcePath(localeMetadata)).toString();
35
+ return new types_1.Locale(localeMetadata.id, localeMetadata.locale ?? slug, content, localeMetadata.resolvingTeamSlug);
36
+ }
37
+ catch (error) {
38
+ throw new errors_1.LocalLocaleNotFound(error, slug);
39
+ }
40
+ }
41
+ async findSlugsNotAdded() {
42
+ const slugsNotAdded = [];
43
+ if (!fs.pathExistsSync(this._repositoryName)) {
44
+ return slugsNotAdded;
45
+ }
46
+ for (const fileName of fs.readdirSync(this._repositoryName)) {
47
+ if (!fileName.endsWith(YAML_EXTENSION)) {
48
+ continue;
49
+ }
50
+ const slug = path.basename(fileName, YAML_EXTENSION);
51
+ if (await this.workspaceConfig.exists('i18n', slug)) {
52
+ continue;
53
+ }
54
+ // Only `pull` writes an inherited file, and it registers it as it does. One left behind by a
55
+ // workspace that no longer inherits it is stale, not a locale waiting to be tracked.
56
+ if (slug.endsWith(types_1.Locale.INHERITED_SUFFIX)) {
57
+ continue;
58
+ }
59
+ if (this.validSlug(slug)) {
60
+ slugsNotAdded.push(slug);
61
+ }
62
+ else {
63
+ cli_1.default.log(color_1.default.yellow(`\n⚠️ local locale file '${fileName}' doesn't have a valid name and will be ignored. ${this.slugFormatMessage()}`));
64
+ }
65
+ }
66
+ return slugsNotAdded;
67
+ }
68
+ getResourcePath(locale) {
69
+ return path.join(this._repositoryName, `${locale.slug ?? locale.locale}${YAML_EXTENSION}`);
70
+ }
71
+ async removeFiles(locale) {
72
+ fs.removeSync(this.getResourcePath(locale));
73
+ this.removeDirectoryIfEmpty(this._repositoryName);
74
+ }
75
+ resourceToMetadata(locale) {
76
+ return {
77
+ id: locale.id,
78
+ locale: locale.locale,
79
+ slug: locale.slug,
80
+ ...(locale.resolvingTeamSlug && { resolvingTeamSlug: locale.resolvingTeamSlug }),
81
+ };
82
+ }
83
+ async saveFiles(locale) {
84
+ // Inherited locales are written read-only, like inherited modules: they belong to a parent
85
+ // workspace, and editing one here would push an override rather than change the parent.
86
+ (0, file_utils_1.outputFileSyncIfChanged)(this.getResourcePath(locale), locale.content || '', { readOnly: Boolean(locale.resolvingTeamSlug) });
87
+ }
88
+ async metadataFromFiles(slug) {
89
+ return {
90
+ locale: slug,
91
+ slug: slug,
92
+ contentHash: '',
93
+ };
94
+ }
95
+ validSlug(slug) {
96
+ return types_1.Locale.LOCALE_REGEX.test(slug);
97
+ }
98
+ slugFormatMessage() {
99
+ return "Use up to 20 letters, numbers, '-' or '_', starting with a letter or a number.";
100
+ }
101
+ }
102
+ exports.default = LocaleRepository;
@@ -19,7 +19,10 @@ class ResourceRepositoryBase {
19
19
  }
20
20
  }
21
21
  async findAll() {
22
- const { [this._repositoryName]: resources } = await this.workspaceConfig.getActiveTeam();
22
+ // Defaulted because a collection added after a workspace was cloned (as `i18n` was) is absent
23
+ // from its sync metadata until the first pull writes it — findAll must see "no resources",
24
+ // not crash every aggregate pull/push/status in every pre-existing workspace.
25
+ const { [this._repositoryName]: resources = {} } = await this.workspaceConfig.getActiveTeam();
23
26
  // eslint-disable-next-line unicorn/no-array-reduce
24
27
  const existResources = await Object.keys(resources).reduce((promise, slug) => promise.then(async (existIds) => {
25
28
  const exists = await this.exists(slug);
@@ -29,7 +29,7 @@ class TeamRepository {
29
29
  }
30
30
  catch { }
31
31
  const parentTeamSlugs = Array.isArray(raw.parentTeamSlugs) ? raw.parentTeamSlugs : [];
32
- const team = new types_1.Team(slug, parentTeamSlugs, raw.zoneId, this.parseErrorHandler(raw.errorHandlerConfig), this.parseWebhookAuth(raw.webhookAuth));
32
+ const team = new types_1.Team(slug, parentTeamSlugs, raw.zoneId, this.parseErrorHandler(raw.errorHandlerConfig), this.parseWebhookAuth(raw.webhookAuth), typeof raw.primaryLocale === 'string' && raw.primaryLocale.length > 0 ? raw.primaryLocale : undefined);
33
33
  // Parsed so a find()+save() round-trip does not drop the pull-only fields.
34
34
  if (Array.isArray(raw.versions)) {
35
35
  team.versions = raw.versions;
@@ -51,7 +51,7 @@ class TeamRepository {
51
51
  tag: raw.errorHandlerConfig.tag ?? null,
52
52
  };
53
53
  }
54
- return new types_1.Team(raw.slug, raw.parentTeamSlugs ?? [], raw.zoneId, errorHandlerConfig, this.parseWebhookAuth(raw.webhookAuth));
54
+ return new types_1.Team(raw.slug, raw.parentTeamSlugs ?? [], raw.zoneId, errorHandlerConfig, this.parseWebhookAuth(raw.webhookAuth), raw.primaryLocale ?? undefined);
55
55
  }
56
56
  /**
57
57
  * Writes the cloud's team versions and aliases into the Team as the pull-only team.json
@@ -111,6 +111,9 @@ class TeamRepository {
111
111
  if (team.webhookAuth !== undefined) {
112
112
  file.webhookAuth = team.webhookAuth;
113
113
  }
114
+ if (team.primaryLocale !== undefined) {
115
+ file.primaryLocale = team.primaryLocale;
116
+ }
114
117
  // Pull-only global versioning fields, appended after the pushable settings.
115
118
  if (team.versions !== undefined) {
116
119
  file.versions = team.versions;
@@ -64,6 +64,7 @@ fcode = FcodeGlobal(
64
64
  Storage(),
65
65
  Schedule(),
66
66
  Variables(env_variables),
67
+ metadata.get("i18n", {}),
67
68
  )
68
69
 
69
70
  logging.basicConfig(level=os.environ.get("FCODE_LOG_LEVEL", logging.INFO))
@@ -78,8 +78,42 @@ class Processes(dict):
78
78
  print(error, file=sys.stderr)
79
79
 
80
80
 
81
+ class I18n:
82
+ """Local equivalent of the cloud's fcode.i18n.
83
+
84
+ The CLI has already layered the chosen locale over the workspace's primary one, so what
85
+ arrives here is a single flat map. Nothing raises: a key with no translation resolves to the
86
+ key itself, and a placeholder with no argument is left exactly as written.
87
+
88
+ The ``options`` argument (the version a call pins in the cloud) is accepted and ignored: a
89
+ workspace directory holds no published snapshots, so a pinned call resolves against the files
90
+ on disk -- the same per-file fallback the cloud applies to a locale never published at that tag.
91
+ """
92
+
93
+ def __init__(self, i18n_payload):
94
+ payload = i18n_payload or {}
95
+ self.__translations = payload.get("translations") or {}
96
+ self.locale = payload.get("locale")
97
+
98
+ def __call__(self, key, args=None, options=None):
99
+ template = self.__translations.get(key)
100
+ if template is None:
101
+ return key
102
+ if not isinstance(args, dict):
103
+ return template
104
+
105
+ import re
106
+
107
+ def replace(match):
108
+ value = args.get(match.group(1).strip())
109
+ return match.group(0) if value is None else str(value)
110
+
111
+ # Single pass, so a substituted value containing %{...} is never rescanned.
112
+ return re.sub(r"%\{([^}]*)\}", replace, template)
113
+
114
+
81
115
  class FcodeGlobal:
82
- def __init__(self, context, execution, env, datastore, storage, schedule, variables):
116
+ def __init__(self, context, execution, env, datastore, storage, schedule, variables, i18n=None):
83
117
  self.context = context
84
118
  self.execution = execution
85
119
  self.team = AttrsInfo({"timezone": "UTC"})
@@ -89,6 +123,7 @@ class FcodeGlobal:
89
123
  self.schedule = schedule
90
124
  self.variables = variables
91
125
  self.processes = Processes()
126
+ self.i18n = I18n(i18n)
92
127
 
93
128
  def send_mail(self, to=None, subject=None, text=None, html=None):
94
129
  # No manager locally: log the email contents instead of sending, so devs
@@ -110,6 +145,13 @@ class FcodeGlobal:
110
145
  return {"messageId": "local-run", "accepted": accepted, "rejected": []}
111
146
 
112
147
  def import_module(self, module_name, version=None):
148
+ """Import a workspace module, optionally at a version.
149
+
150
+ The version is either a bare string or an options dict ({"version": "v1"}), the form
151
+ every other versioned helper takes — the cloud reads both, so a local run must too.
152
+ """
153
+ if isinstance(version, dict):
154
+ version = version.get("version")
113
155
  module_path = module_name
114
156
  if version:
115
157
  module_path = os.path.join(module_path, "versions", version)
@@ -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;
@@ -22,6 +22,16 @@ declare class FcodeGraphQLClient {
22
22
  allVersionedScriptLibraries(scriptLibraryId: string): Promise<Array<VersionedScriptLibrary>>;
23
23
  createVersionedScriptLibrary(versionedScriptLibrary: VersionedScriptLibrary): Promise<void>;
24
24
  allVariables(): Promise<Array<Variable>>;
25
+ /**
26
+ * Every locale the workspace resolves: its own files, plus one merged file per identifier it
27
+ * inherits. A locale the workspace and a parent both define is listed twice by the platform and
28
+ * kept twice here — an override is layered over the parent's file key by key, so the parent's
29
+ * keys are still what the ones it does not mention resolve to.
30
+ */
31
+ allLocales(): Promise<Array<Locale>>;
32
+ pushLocale(locale: Locale): Promise<Locale>;
33
+ updateLocale(locale: Locale): Promise<Locale>;
34
+ removeLocale(locale: string | undefined): Promise<void>;
25
35
  private toProcessInput;
26
36
  pushProcess(process: Process): Promise<Process>;
27
37
  removeProcess(id: String | undefined): Promise<Process>;
@@ -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 = `
@@ -302,6 +303,111 @@ class FcodeGraphQLClient {
302
303
  }
303
304
  return variables;
304
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
+ }
333
+ }
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
+ }
361
+ }
362
+ `, {
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,
384
+ })
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)
407
+ .catch((error) => {
408
+ return Promise.reject(this.cleanError(error));
409
+ });
410
+ }
305
411
  toProcessInput(process, includeCreateOnly) {
306
412
  const settings = {
307
413
  dependencies: { scopedToProcess: process.settings.dependencies.scopedToProcess },
@@ -639,6 +745,9 @@ class FcodeGraphQLClient {
639
745
  settings.webhookAuth.headerName = webhookAuth.headerName;
640
746
  }
641
747
  }
748
+ if (team.settings?.primaryLocale) {
749
+ settings.primaryLocale = team.settings.primaryLocale;
750
+ }
642
751
  return settings;
643
752
  }
644
753
  mapTeamVersion(teamVersion) {
@@ -768,6 +877,7 @@ class FcodeGraphQLClient {
768
877
  headerName
769
878
  variableKey
770
879
  }
880
+ primaryLocale
771
881
  }
772
882
  }
773
883
  }
@@ -794,6 +904,7 @@ class FcodeGraphQLClient {
794
904
  headerName
795
905
  variableKey
796
906
  }
907
+ primaryLocale
797
908
  }
798
909
  }
799
910
  }