@factorialco/fcode-cli 2.4.0 → 3.0.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 (114) hide show
  1. package/README.md +331 -118
  2. package/dist/commands/clone.d.ts +0 -12
  3. package/dist/commands/clone.js +22 -135
  4. package/dist/commands/{settings → config}/get.d.ts +1 -1
  5. package/dist/commands/{settings → config}/get.js +7 -7
  6. package/dist/commands/{settings → config}/remove.d.ts +1 -1
  7. package/dist/commands/{settings → config}/remove.js +6 -6
  8. package/dist/commands/{settings → config}/set.d.ts +1 -1
  9. package/dist/commands/{settings → config}/set.js +8 -8
  10. package/dist/commands/dependencies/diff.d.ts +9 -0
  11. package/dist/commands/dependencies/diff.js +15 -0
  12. package/dist/commands/dependencies/pull.d.ts +9 -0
  13. package/dist/commands/dependencies/pull.js +44 -1
  14. package/dist/commands/diff.d.ts +12 -0
  15. package/dist/commands/diff.js +48 -0
  16. package/dist/commands/http.d.ts +1 -1
  17. package/dist/commands/http.js +7 -7
  18. package/dist/commands/i18n/diff.d.ts +9 -0
  19. package/dist/commands/i18n/diff.js +15 -0
  20. package/dist/commands/i18n/status.d.ts +3 -0
  21. package/dist/commands/i18n/status.js +1 -0
  22. package/dist/commands/modules/diff.d.ts +9 -0
  23. package/dist/commands/modules/diff.js +15 -0
  24. package/dist/commands/modules/status.d.ts +3 -0
  25. package/dist/commands/modules/status.js +1 -0
  26. package/dist/commands/processes/diff.d.ts +9 -0
  27. package/dist/commands/processes/diff.js +15 -0
  28. package/dist/commands/processes/status.d.ts +3 -0
  29. package/dist/commands/processes/status.js +1 -0
  30. package/dist/commands/pull.js +2 -2
  31. package/dist/commands/push.js +2 -2
  32. package/dist/commands/{team → settings}/aliases/delete.d.ts +1 -1
  33. package/dist/commands/{team → settings}/aliases/delete.js +6 -6
  34. package/dist/commands/{team → settings}/aliases/list.d.ts +1 -1
  35. package/dist/commands/{team → settings}/aliases/list.js +8 -8
  36. package/dist/commands/{team → settings}/aliases/set.d.ts +1 -1
  37. package/dist/commands/{team → settings}/aliases/set.js +8 -8
  38. package/dist/commands/settings/diff.d.ts +8 -0
  39. package/dist/commands/settings/diff.js +39 -0
  40. package/dist/commands/settings/pull.d.ts +11 -0
  41. package/dist/commands/settings/pull.js +41 -0
  42. package/dist/commands/{team → settings}/push.d.ts +4 -4
  43. package/dist/commands/{team → settings}/push.js +39 -39
  44. package/dist/commands/settings/status.d.ts +8 -0
  45. package/dist/commands/settings/status.js +46 -0
  46. package/dist/commands/{team → settings}/versions/create.d.ts +1 -1
  47. package/dist/commands/{team → settings}/versions/create.js +13 -10
  48. package/dist/commands/{team → settings}/versions/delete.d.ts +1 -1
  49. package/dist/commands/{team → settings}/versions/delete.js +10 -10
  50. package/dist/commands/{team → settings}/versions/list.d.ts +1 -1
  51. package/dist/commands/{team → settings}/versions/list.js +8 -8
  52. package/dist/commands/status.d.ts +3 -0
  53. package/dist/commands/status.js +10 -7
  54. package/dist/commands/team/clone.d.ts +14 -0
  55. package/dist/commands/team/clone.js +91 -0
  56. package/dist/commands/team/pull.d.ts +3 -7
  57. package/dist/commands/team/pull.js +25 -30
  58. package/dist/commands/team/status.d.ts +3 -4
  59. package/dist/commands/team/status.js +34 -33
  60. package/dist/commands/variables/diff.d.ts +9 -0
  61. package/dist/commands/variables/diff.js +15 -0
  62. package/dist/commands/variables/status.d.ts +3 -0
  63. package/dist/commands/variables/status.js +1 -0
  64. package/dist/config/dev-team.d.ts +48 -0
  65. package/dist/config/dev-team.js +62 -0
  66. package/dist/config/workspace.d.ts +2 -2
  67. package/dist/config/workspace.js +4 -2
  68. package/dist/core/commands/base-command.js +5 -0
  69. package/dist/core/commands/dependency-command.d.ts +2 -1
  70. package/dist/core/commands/dependency-command.js +3 -0
  71. package/dist/core/commands/i18n-command.js +2 -0
  72. package/dist/core/commands/modules-command.js +2 -0
  73. package/dist/core/commands/processes-command.js +2 -0
  74. package/dist/core/commands/resource-command.d.ts +26 -1
  75. package/dist/core/commands/resource-command.js +100 -3
  76. package/dist/core/commands/variables-command.d.ts +7 -0
  77. package/dist/core/commands/variables-command.js +13 -0
  78. package/dist/core/executors/javascript-execution-task.js +25 -3
  79. package/dist/core/types.d.ts +30 -7
  80. package/dist/core/types.js +38 -10
  81. package/dist/repositories/process.d.ts +8 -2
  82. package/dist/repositories/process.js +28 -18
  83. package/dist/repositories/programming-language-manifest.d.ts +15 -1
  84. package/dist/repositories/programming-language-manifest.js +45 -1
  85. package/dist/repositories/settings.d.ts +52 -0
  86. package/dist/repositories/settings.js +214 -0
  87. package/dist/runtime/libs/python/fcode/context.py +29 -2
  88. package/dist/services/dashboard-client.d.ts +31 -0
  89. package/dist/services/dashboard-client.js +99 -0
  90. package/dist/services/dev-team-service.d.ts +41 -0
  91. package/dist/services/dev-team-service.js +162 -0
  92. package/dist/services/fcode-graphql-client.d.ts +16 -2
  93. package/dist/services/fcode-graphql-client.js +56 -7
  94. package/dist/services/locale-resolver.d.ts +16 -5
  95. package/dist/services/locale-resolver.js +60 -12
  96. package/dist/services/resource-differ.d.ts +60 -0
  97. package/dist/services/resource-differ.js +135 -0
  98. package/dist/services/skills-installer.d.ts +16 -0
  99. package/dist/services/skills-installer.js +77 -0
  100. package/dist/services/team-versioning.d.ts +4 -4
  101. package/dist/services/team-versioning.js +4 -4
  102. package/dist/services/webhook-auth.js +1 -1
  103. package/dist/services/workspace-cloner.d.ts +31 -0
  104. package/dist/services/workspace-cloner.js +95 -0
  105. package/dist/utils/dev-slug.d.ts +12 -0
  106. package/dist/utils/dev-slug.js +37 -0
  107. package/dist/utils/diff.d.ts +19 -0
  108. package/dist/utils/diff.js +58 -0
  109. package/dist/utils/slugify.d.ts +17 -0
  110. package/dist/utils/slugify.js +41 -0
  111. package/oclif.manifest.json +428 -116
  112. package/package.json +12 -7
  113. package/dist/repositories/team.d.ts +0 -41
  114. package/dist/repositories/team.js +0 -170
@@ -17,6 +17,7 @@ exports.INHERITED_FILE_NAME = {
17
17
  PYTHON: 'requirements.inherited.txt',
18
18
  };
19
19
  exports.DEPENDENCIES_DIR = 'dependencies';
20
+ const VERSIONS_DIR = 'versions';
20
21
  class ProgrammingLanguageManifestRepository extends resource_1.default {
21
22
  constructor() {
22
23
  super({
@@ -38,7 +39,10 @@ class ProgrammingLanguageManifestRepository extends resource_1.default {
38
39
  }
39
40
  }
40
41
  getSourceCodeFileName(programmingLanguageManifest) {
41
- return programmingLanguageManifest.programmingLanguage === 'JAVASCRIPT' ? 'package.json' : 'requirements.txt';
42
+ return this.getSourceCodeFileNameFor(programmingLanguageManifest.programmingLanguage);
43
+ }
44
+ getSourceCodeFileNameFor(programmingLanguage) {
45
+ return programmingLanguage === 'JAVASCRIPT' ? 'package.json' : 'requirements.txt';
42
46
  }
43
47
  /**
44
48
  * The inherited dependencies of one language, collapsed into a single file the way inherited
@@ -131,6 +135,46 @@ class ProgrammingLanguageManifestRepository extends resource_1.default {
131
135
  slugFormatMessage() {
132
136
  return 'Only JAVASCRIPT or PYTHON are allowed.';
133
137
  }
138
+ /**
139
+ * Published snapshots live under `dependencies/versions/<tag>/`, one manifest file per language,
140
+ * mirroring the `versions/` folders of processes and modules. Written read-only: unlike those,
141
+ * these are pulled for visibility only — `fcode settings:versions:create` is what publishes them, and
142
+ * editing one here would push nothing.
143
+ */
144
+ versionsDirectory() {
145
+ return path.join(this._repositoryName, VERSIONS_DIR);
146
+ }
147
+ versionDirectory(tag) {
148
+ return path.join(this.versionsDirectory(), tag);
149
+ }
150
+ versionPath(version) {
151
+ return path.join(this.versionDirectory(version.tag), this.getSourceCodeFileNameFor(version.programmingLanguage));
152
+ }
153
+ getLocalVersionTags() {
154
+ const versionsDir = this.versionsDirectory();
155
+ if (!fs.pathExistsSync(versionsDir)) {
156
+ return [];
157
+ }
158
+ return fs.readdirSync(versionsDir).filter((name) => this.validVersion(name));
159
+ }
160
+ versionExistsLocally(version) {
161
+ return fs.pathExistsSync(this.versionPath(version));
162
+ }
163
+ removeVersion(tag) {
164
+ const versionDirectory = this.versionDirectory(tag);
165
+ if (!fs.pathExistsSync(versionDirectory)) {
166
+ return;
167
+ }
168
+ // Written read-only, so every file has to be made writable before the directory can go.
169
+ for (const fileName of fs.readdirSync(versionDirectory)) {
170
+ fs.chmodSync(path.join(versionDirectory, fileName), 0o644);
171
+ }
172
+ fs.removeSync(versionDirectory);
173
+ this.removeDirectoryIfEmpty(this.versionsDirectory());
174
+ }
175
+ async saveVersion(version) {
176
+ (0, file_utils_1.outputFileSyncIfChanged)(this.versionPath(version), (0, dependencies_1.dependenciesToFile)(version.programmingLanguage, version.dependencies), { readOnly: true });
177
+ }
134
178
  async findSlugsNotAdded() {
135
179
  const slugsNotAdded = [];
136
180
  if (!(await this.workspaceConfig.exists('dependencies', 'JAVASCRIPT')) && fs.existsSync(path.join(this._repositoryName, 'package.json'))) {
@@ -0,0 +1,52 @@
1
+ import { RawTeamSettings, TeamVersion, TeamVersionAlias, WorkspaceSettings } from '../core/types';
2
+ import WorkspaceConfig from '../config/workspace';
3
+ export declare const SETTINGS_FILENAME = "settings.json";
4
+ /**
5
+ * Workspaces cloned before the file was renamed carry team.json. Renaming it on first access keeps
6
+ * the stored sync hash valid (it is computed over the content, not the name), so the workspace does
7
+ * not report a phantom conflict. Only fires when the new name is absent, so a workspace holding both
8
+ * is left alone.
9
+ */
10
+ export declare function migrateLegacySettingsFile(workspaceDir?: string): void;
11
+ /**
12
+ * Reads/writes the singleton settings.json (timezone, error handler, inheritance, webhook auth) at
13
+ * the workspace root. Unlike collection resources this is not a ResourceRepositoryBase:
14
+ * a workspace has exactly one settings file, so there is no slug-keyed collection to manage.
15
+ *
16
+ * settings.json references the error-handler process by slug for portability; the cloud
17
+ * references it by id, so this repository resolves slug<->id against the tracked
18
+ * process metadata in .fcode/.
19
+ */
20
+ export default class SettingsRepository {
21
+ protected workspaceConfig: WorkspaceConfig;
22
+ exists(): boolean;
23
+ find(): Promise<WorkspaceSettings>;
24
+ save(settings: WorkspaceSettings): void;
25
+ /** Builds the settings from the cloud's raw payload, resolving the error-handler id to a slug. */
26
+ fromRaw(raw: RawTeamSettings): Promise<WorkspaceSettings>;
27
+ /**
28
+ * Writes the cloud's workspace versions and aliases into the settings as the settings.json
29
+ * versioning fields (sorted for stable git diffs). Cloud entries always carry a createdAt; only
30
+ * hand-added local entries lack one, and those never pass through here.
31
+ */
32
+ applyCloudVersioning(settings: WorkspaceSettings, versions: TeamVersion[], aliases: TeamVersionAlias[]): void;
33
+ resolveSlugToId(processSlug: string): Promise<string>;
34
+ resolveIdToSlug(processId: string): Promise<string>;
35
+ private parseErrorHandler;
36
+ /**
37
+ * settings.json is hand-edited to declare versions, so entries without a tag are dropped and a
38
+ * duplicated tag keeps its first entry (a tag names one immutable snapshot).
39
+ */
40
+ private parseVersions;
41
+ /**
42
+ * An alias is a name->tag pointer; entries missing either half are dropped and a duplicated
43
+ * name keeps its first entry.
44
+ */
45
+ private parseAliases;
46
+ /** A configuration without a variable key is meaningless, so it is read as absent. */
47
+ private parseWebhookAuth;
48
+ /** Accepts plain slugs (unpinned) next to { slug, version } objects, in file order. */
49
+ private parseParentTeams;
50
+ /** The settings.json shape. Public so `fcode diff` renders cloud settings into the same bytes a pull would write. */
51
+ toFile(settings: WorkspaceSettings): Record<string, any>;
52
+ }
@@ -0,0 +1,214 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SETTINGS_FILENAME = void 0;
4
+ exports.migrateLegacySettingsFile = migrateLegacySettingsFile;
5
+ const fs = require("fs-extra");
6
+ const path = require("node:path");
7
+ const types_1 = require("../core/types");
8
+ const workspace_1 = require("../config/workspace");
9
+ const file_utils_1 = require("../utils/file-utils");
10
+ exports.SETTINGS_FILENAME = 'settings.json';
11
+ const LEGACY_SETTINGS_FILENAME = 'team.json';
12
+ /**
13
+ * Workspaces cloned before the file was renamed carry team.json. Renaming it on first access keeps
14
+ * the stored sync hash valid (it is computed over the content, not the name), so the workspace does
15
+ * not report a phantom conflict. Only fires when the new name is absent, so a workspace holding both
16
+ * is left alone.
17
+ */
18
+ function migrateLegacySettingsFile(workspaceDir = process.cwd()) {
19
+ const legacy = path.join(workspaceDir, LEGACY_SETTINGS_FILENAME);
20
+ const current = path.join(workspaceDir, exports.SETTINGS_FILENAME);
21
+ if (fs.pathExistsSync(legacy) && !fs.pathExistsSync(current)) {
22
+ fs.renameSync(legacy, current);
23
+ }
24
+ }
25
+ /**
26
+ * Reads/writes the singleton settings.json (timezone, error handler, inheritance, webhook auth) at
27
+ * the workspace root. Unlike collection resources this is not a ResourceRepositoryBase:
28
+ * a workspace has exactly one settings file, so there is no slug-keyed collection to manage.
29
+ *
30
+ * settings.json references the error-handler process by slug for portability; the cloud
31
+ * references it by id, so this repository resolves slug<->id against the tracked
32
+ * process metadata in .fcode/.
33
+ */
34
+ class SettingsRepository {
35
+ constructor() {
36
+ this.workspaceConfig = new workspace_1.default();
37
+ }
38
+ exists() {
39
+ migrateLegacySettingsFile();
40
+ return fs.pathExistsSync(exports.SETTINGS_FILENAME);
41
+ }
42
+ async find() {
43
+ migrateLegacySettingsFile();
44
+ const slug = await this.workspaceConfig.getActiveTeamSlug();
45
+ let raw = {};
46
+ try {
47
+ raw = fs.readJsonSync(exports.SETTINGS_FILENAME) || {};
48
+ }
49
+ catch { }
50
+ const settings = new types_1.WorkspaceSettings(slug, this.parseParentTeams(raw.parentTeams), raw.zoneId, this.parseErrorHandler(raw.errorHandlerConfig), this.parseWebhookAuth(raw.webhookAuth), typeof raw.primaryLocale === 'string' && raw.primaryLocale.length > 0 ? raw.primaryLocale : undefined);
51
+ if (Array.isArray(raw.versions)) {
52
+ settings.versions = this.parseVersions(raw.versions);
53
+ }
54
+ if (Array.isArray(raw.aliases)) {
55
+ settings.aliases = this.parseAliases(raw.aliases);
56
+ }
57
+ return settings;
58
+ }
59
+ save(settings) {
60
+ migrateLegacySettingsFile();
61
+ (0, file_utils_1.outputFileSyncIfChanged)(exports.SETTINGS_FILENAME, JSON.stringify(this.toFile(settings), null, 2));
62
+ }
63
+ /** Builds the settings from the cloud's raw payload, resolving the error-handler id to a slug. */
64
+ async fromRaw(raw) {
65
+ let errorHandlerConfig;
66
+ if (raw.errorHandlerConfig?.processId) {
67
+ errorHandlerConfig = {
68
+ processSlug: await this.resolveIdToSlug(raw.errorHandlerConfig.processId),
69
+ tag: raw.errorHandlerConfig.tag ?? null,
70
+ };
71
+ }
72
+ return new types_1.WorkspaceSettings(raw.slug, raw.parentTeams ?? [], raw.zoneId, errorHandlerConfig, this.parseWebhookAuth(raw.webhookAuth), raw.primaryLocale ?? undefined);
73
+ }
74
+ /**
75
+ * Writes the cloud's workspace versions and aliases into the settings as the settings.json
76
+ * versioning fields (sorted for stable git diffs). Cloud entries always carry a createdAt; only
77
+ * hand-added local entries lack one, and those never pass through here.
78
+ */
79
+ applyCloudVersioning(settings, versions, aliases) {
80
+ settings.versions = versions
81
+ .map((version) => {
82
+ const entry = { tag: version.tag, createdAt: version.createdAt };
83
+ if (version.comment) {
84
+ entry.comment = version.comment;
85
+ }
86
+ return entry;
87
+ })
88
+ .sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
89
+ settings.aliases = aliases
90
+ .map((alias) => ({ name: alias.name, tag: alias.tag }))
91
+ .sort((a, b) => a.name.localeCompare(b.name));
92
+ }
93
+ async resolveSlugToId(processSlug) {
94
+ const { processes } = await this.workspaceConfig.getActiveTeam();
95
+ return processes[processSlug]?.id ?? processSlug;
96
+ }
97
+ async resolveIdToSlug(processId) {
98
+ const { processes } = await this.workspaceConfig.getActiveTeam();
99
+ const match = Object.values(processes).find((process) => process.id === processId);
100
+ return match?.slug ?? processId;
101
+ }
102
+ parseErrorHandler(raw) {
103
+ if (!raw || !raw.processSlug) {
104
+ return undefined;
105
+ }
106
+ return { processSlug: raw.processSlug, tag: raw.tag ?? null };
107
+ }
108
+ /**
109
+ * settings.json is hand-edited to declare versions, so entries without a tag are dropped and a
110
+ * duplicated tag keeps its first entry (a tag names one immutable snapshot).
111
+ */
112
+ parseVersions(raw) {
113
+ const versions = [];
114
+ for (const candidate of raw) {
115
+ if (!candidate || typeof candidate.tag !== 'string' || candidate.tag.length === 0) {
116
+ continue;
117
+ }
118
+ if (versions.some(({ tag }) => tag === candidate.tag)) {
119
+ continue;
120
+ }
121
+ const entry = { tag: candidate.tag };
122
+ if (typeof candidate.comment === 'string' && candidate.comment.length > 0) {
123
+ entry.comment = candidate.comment;
124
+ }
125
+ if (typeof candidate.createdAt === 'string' && candidate.createdAt.length > 0) {
126
+ entry.createdAt = candidate.createdAt;
127
+ }
128
+ versions.push(entry);
129
+ }
130
+ return versions;
131
+ }
132
+ /**
133
+ * An alias is a name->tag pointer; entries missing either half are dropped and a duplicated
134
+ * name keeps its first entry.
135
+ */
136
+ parseAliases(raw) {
137
+ const aliases = [];
138
+ for (const candidate of raw) {
139
+ if (!candidate || typeof candidate.name !== 'string' || candidate.name.length === 0) {
140
+ continue;
141
+ }
142
+ if (typeof candidate.tag !== 'string' || candidate.tag.length === 0) {
143
+ continue;
144
+ }
145
+ if (aliases.some(({ name }) => name === candidate.name)) {
146
+ continue;
147
+ }
148
+ aliases.push({ name: candidate.name, tag: candidate.tag });
149
+ }
150
+ return aliases;
151
+ }
152
+ /** A configuration without a variable key is meaningless, so it is read as absent. */
153
+ parseWebhookAuth(raw) {
154
+ if (!raw || typeof raw.variableKey !== 'string' || raw.variableKey.length === 0) {
155
+ return undefined;
156
+ }
157
+ const webhookAuth = { variableKey: raw.variableKey };
158
+ // The default header is left out so a pull writes the minimal form.
159
+ if (typeof raw.headerName === 'string' && raw.headerName.length > 0 && raw.headerName !== types_1.DEFAULT_WEBHOOK_AUTH_HEADER) {
160
+ webhookAuth.headerName = raw.headerName;
161
+ }
162
+ return webhookAuth;
163
+ }
164
+ /** Accepts plain slugs (unpinned) next to { slug, version } objects, in file order. */
165
+ parseParentTeams(raw) {
166
+ if (!Array.isArray(raw)) {
167
+ return [];
168
+ }
169
+ const refs = [];
170
+ for (const entry of raw) {
171
+ if (typeof entry === 'string' && entry.length > 0) {
172
+ refs.push({ slug: entry });
173
+ }
174
+ else if (entry && typeof entry === 'object' && typeof entry.slug === 'string' && entry.slug.length > 0) {
175
+ const ref = { slug: entry.slug };
176
+ if (typeof entry.version === 'string' && entry.version.length > 0) {
177
+ ref.version = entry.version;
178
+ }
179
+ refs.push(ref);
180
+ }
181
+ }
182
+ return refs;
183
+ }
184
+ /** The settings.json shape. Public so `fcode diff` renders cloud settings into the same bytes a pull would write. */
185
+ toFile(settings) {
186
+ // Unpinned parents are written as plain slugs (the minimal form, and what a hand-written
187
+ // settings.json most likely contains); pinned ones as { slug, version } objects.
188
+ const file = {
189
+ parentTeams: (settings.parentTeams ?? []).map((ref) => (ref.version ? { slug: ref.slug, version: ref.version } : ref.slug)),
190
+ };
191
+ if (settings.zoneId !== undefined) {
192
+ file.zoneId = settings.zoneId;
193
+ }
194
+ if (settings.errorHandlerConfig !== undefined) {
195
+ file.errorHandlerConfig = settings.errorHandlerConfig;
196
+ }
197
+ // Last, so adding webhook auth to an existing settings.json is a purely additive diff.
198
+ if (settings.webhookAuth !== undefined) {
199
+ file.webhookAuth = settings.webhookAuth;
200
+ }
201
+ if (settings.primaryLocale !== undefined) {
202
+ file.primaryLocale = settings.primaryLocale;
203
+ }
204
+ // Global versioning fields, appended after the scalar settings.
205
+ if (settings.versions !== undefined) {
206
+ file.versions = settings.versions;
207
+ }
208
+ if (settings.aliases !== undefined) {
209
+ file.aliases = settings.aliases;
210
+ }
211
+ return file;
212
+ }
213
+ }
214
+ exports.default = SettingsRepository;
@@ -100,8 +100,11 @@ class I18n:
100
100
 
101
101
  def __call__(self, key, args=None, options=None):
102
102
  locale = options.get("locale") if isinstance(options, dict) else None
103
- locale_map = self.__by_locale.get(str(locale)) if locale is not None else None
104
- template = locale_map.get(key) if locale_map is not None else None
103
+ template = None
104
+ for locale_map in self.__locale_chain(str(locale) if locale is not None else None):
105
+ template = locale_map.get(key)
106
+ if template is not None:
107
+ break
105
108
  if template is None:
106
109
  template = self.__translations.get(key)
107
110
  if template is None:
@@ -118,6 +121,30 @@ class I18n:
118
121
  # Single pass, so a substituted value containing %{...} is never rescanned.
119
122
  return re.sub(r"%\{([^}]*)\}", replace, template)
120
123
 
124
+ def __locale_chain(self, locale):
125
+ """The maps a requested locale opens, most specific first.
126
+
127
+ An identifier no locale matches is shortened one segment at a time ("en-gb" reads "en"),
128
+ by name first and ignoring case only as a last resort -- the same chain the cloud
129
+ executors resolve, so a local run answers a regional variant the same way.
130
+ """
131
+ if locale is None or not self.__by_locale:
132
+ return []
133
+ names = list(self.__by_locale.keys())
134
+ chain = []
135
+ candidate = locale
136
+ while candidate:
137
+ if candidate in names:
138
+ match = candidate
139
+ else:
140
+ folded = sorted(n for n in names if n.lower() == candidate.lower())
141
+ match = folded[0] if folded else None
142
+ if match is not None and match not in chain:
143
+ chain.append(match)
144
+ last_separator = max(candidate.rfind("-"), candidate.rfind("_"))
145
+ candidate = candidate[:last_separator] if last_separator > 0 else None
146
+ return [self.__by_locale[name] for name in chain if self.__by_locale[name]]
147
+
121
148
 
122
149
  class FcodeGlobal:
123
150
  def __init__(self, context, execution, env, datastore, storage, schedule, variables, i18n=None):
@@ -0,0 +1,31 @@
1
+ import GlobalConfig from '../config/global';
2
+ export interface DevelopmentTeam {
3
+ id: string;
4
+ name: string;
5
+ }
6
+ export interface DevelopmentTeamApp {
7
+ id: string;
8
+ name: string;
9
+ description: string | null;
10
+ }
11
+ /**
12
+ * `https://code.factorialhr.com/platform` → `https://code.factorialhr.com/dashboard/api/v1`.
13
+ * The dashboard is served under `/dashboard` on the same origin as the platform; environments
14
+ * that split them can point `FCODE_DASHBOARD_URL` at the API base directly.
15
+ */
16
+ export declare function dashboardApiUrl(cloudUrl: string): string;
17
+ export default class DashboardClient {
18
+ private readonly cloudUrl;
19
+ private readonly baseUrl;
20
+ /** Instance property so tests can supply a stub instead of the real `~/.fcode/fcode.json`. */
21
+ globalConfig: GlobalConfig;
22
+ constructor(cloudUrl: string);
23
+ /** The development teams the session belongs to. Operators get every team. */
24
+ listDevelopmentTeams(): Promise<DevelopmentTeam[]>;
25
+ /** The Apps owned by a development team, narrowed by the API to the ones the session can see. */
26
+ listTeamApps(teamId: string): Promise<DevelopmentTeamApp[]>;
27
+ private getAllPages;
28
+ private get;
29
+ private headers;
30
+ private errorDetail;
31
+ }
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.dashboardApiUrl = dashboardApiUrl;
4
+ const node_fetch_1 = require("node-fetch");
5
+ const auth_service_1 = require("./auth-service");
6
+ const global_1 = require("../config/global");
7
+ /**
8
+ * The dashboard's control plane — where Apps and development teams live — as opposed to the
9
+ * platform GraphQL API that `FcodeGraphQLClient` talks to. Both sit behind the same host and
10
+ * accept the same Keycloak token: the dashboard verifies the realm signature and issuer, so the
11
+ * session `fcode login` already established is enough.
12
+ */
13
+ /** Max the dashboard's list endpoints accept (`listQuerySchema`), so a team is one round trip. */
14
+ const PAGE_SIZE = 200;
15
+ /**
16
+ * `https://code.factorialhr.com/platform` → `https://code.factorialhr.com/dashboard/api/v1`.
17
+ * The dashboard is served under `/dashboard` on the same origin as the platform; environments
18
+ * that split them can point `FCODE_DASHBOARD_URL` at the API base directly.
19
+ */
20
+ function dashboardApiUrl(cloudUrl) {
21
+ const override = process.env.FCODE_DASHBOARD_URL;
22
+ if (override) {
23
+ return override.replace(/\/+$/, '');
24
+ }
25
+ return `${new URL(cloudUrl).origin}/dashboard/api/v1`;
26
+ }
27
+ class DashboardClient {
28
+ constructor(cloudUrl) {
29
+ this.cloudUrl = cloudUrl;
30
+ /** Instance property so tests can supply a stub instead of the real `~/.fcode/fcode.json`. */
31
+ this.globalConfig = new global_1.default();
32
+ this.baseUrl = dashboardApiUrl(cloudUrl);
33
+ }
34
+ /** The development teams the session belongs to. Operators get every team. */
35
+ async listDevelopmentTeams() {
36
+ const items = await this.getAllPages('/development-teams');
37
+ return items.map(({ id, company_name }) => ({ id, name: company_name }));
38
+ }
39
+ /** The Apps owned by a development team, narrowed by the API to the ones the session can see. */
40
+ async listTeamApps(teamId) {
41
+ const items = await this.getAllPages('/apps', {
42
+ development_team_id: teamId,
43
+ sort_by: 'name',
44
+ sort_dir: 'asc',
45
+ });
46
+ return items.map(({ id, name, description }) => ({ id, name, description }));
47
+ }
48
+ async getAllPages(path, query = {}) {
49
+ const items = [];
50
+ let offset = 0;
51
+ // `total` is authoritative rather than "a short page means the end": a page can come back
52
+ // short when the API narrows it to what the session may see.
53
+ let total = Number.POSITIVE_INFINITY;
54
+ while (items.length < total) {
55
+ const page = await this.get(path, {
56
+ ...query,
57
+ limit: String(PAGE_SIZE),
58
+ offset: String(offset),
59
+ });
60
+ items.push(...page.items);
61
+ total = page.total;
62
+ if (page.items.length === 0) {
63
+ break;
64
+ }
65
+ offset += page.items.length;
66
+ }
67
+ return items;
68
+ }
69
+ async get(path, query) {
70
+ const url = `${this.baseUrl}${path}?${new URLSearchParams(query).toString()}`;
71
+ let response = await (0, node_fetch_1.default)(url, { headers: await this.headers() });
72
+ // The token's group membership is snapshotted at issuance (FCT-61231), so a team joined after
73
+ // login reads as a denial. Re-mint once and retry before believing it.
74
+ if (response.status === 401 || response.status === 403) {
75
+ if (await auth_service_1.default.refreshSession(this.cloudUrl)) {
76
+ response = await (0, node_fetch_1.default)(url, { headers: await this.headers() });
77
+ }
78
+ }
79
+ if (!response.ok) {
80
+ throw new Error(`Dashboard API ${response.status} on ${path}: ${await this.errorDetail(response)}`);
81
+ }
82
+ return (await response.json());
83
+ }
84
+ async headers() {
85
+ const { token } = await this.globalConfig.getAuthInfo(this.cloudUrl);
86
+ return { accept: 'application/json', authorization: `Bearer ${token.access_token}` };
87
+ }
88
+ async errorDetail(response) {
89
+ try {
90
+ const body = await response.text();
91
+ const parsed = JSON.parse(body);
92
+ return parsed.detail ?? parsed.message ?? body;
93
+ }
94
+ catch {
95
+ return 'no response body';
96
+ }
97
+ }
98
+ }
99
+ exports.default = DashboardClient;
@@ -0,0 +1,41 @@
1
+ import DashboardClient, { DevelopmentTeam, DevelopmentTeamApp } from './dashboard-client';
2
+ import { DevTeamApp, DevTeamMetadata } from '../config/dev-team';
3
+ /** What a team folder has to do to match the team as the dashboard currently describes it. */
4
+ export interface AppSyncPlan {
5
+ /** In the team, with no folder yet — cloned. */
6
+ toClone: DevelopmentTeamApp[];
7
+ /** In the team and already cloned — refreshed and pulled. */
8
+ toRefresh: DevelopmentTeamApp[];
9
+ /** Recorded locally but no longer in the team — reported, never deleted. */
10
+ removed: Array<{
11
+ id: string;
12
+ app: DevTeamApp;
13
+ }>;
14
+ }
15
+ export declare function planAppSync(metadata: DevTeamMetadata, apps: DevelopmentTeamApp[]): AppSyncPlan;
16
+ /**
17
+ * The folder an App gets inside the team root. Names are free text and two Apps may well slugify
18
+ * the same, so the first claimant keeps the plain slug and the next ones are suffixed. Only ever
19
+ * called for an App without a recorded folder — afterwards the team metadata is authoritative, so
20
+ * a renamed App keeps the folder it was cloned into.
21
+ */
22
+ export declare function assignFolder(app: DevelopmentTeamApp, metadata: DevTeamMetadata): string;
23
+ export interface AppSyncOutcome {
24
+ name: string;
25
+ folder: string;
26
+ action: 'cloned' | 'pulled';
27
+ error?: Error;
28
+ }
29
+ /**
30
+ * Applies the plan, one App at a time, recording the metadata of each App that lands. A failing
31
+ * App is reported and the run carries on: with a dozen Apps, one bad workspace must not cost the
32
+ * other eleven.
33
+ */
34
+ export declare function syncApps(rootDir: string, metadata: DevTeamMetadata, plan: AppSyncPlan): Promise<AppSyncOutcome[]>;
35
+ /** Prints the per-App result and reports whether anything failed. */
36
+ export declare function reportOutcomes(outcomes: AppSyncOutcome[], removed: AppSyncPlan['removed']): boolean;
37
+ /**
38
+ * The team to operate on: the one named, the only one the session belongs to, or — when there are
39
+ * several — none, after listing them so the user can pass an id.
40
+ */
41
+ export declare function resolveTeam(client: DashboardClient, teamId?: string): Promise<DevelopmentTeam | undefined>;