fraim 2.0.248 → 2.0.249

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.
@@ -0,0 +1,248 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.openGitHubPullRequest = exports.ALLOWED_GIT_URL = void 0;
7
+ exports.redactGitUrl = redactGitUrl;
8
+ exports.gitCompareUrl = gitCompareUrl;
9
+ exports.parseGitHubSlug = parseGitHubSlug;
10
+ exports.describePackChange = describePackChange;
11
+ exports.publishPackToGitRepo = publishPackToGitRepo;
12
+ /**
13
+ * Shared git-backed publish for the manager and org packs (issue #1043).
14
+ *
15
+ * Before this module, manager-publish.ts and org-publish.ts each carried a
16
+ * byte-identical clone/branch/commit/push block and their own copy of
17
+ * gitCompareUrl. Both stopped at a `/compare/<branch>?expand=1` link, which is
18
+ * not a pull request: it is a form a human still has to open and submit. R4
19
+ * requires a repo-backed share to produce the PR itself.
20
+ *
21
+ * A move is also not just a write. When the source and the destination are in
22
+ * the same repo-backed pack, the destination write and the source removal must
23
+ * land in ONE commit, so the invariant "exactly one live copy" is never briefly
24
+ * false on the branch.
25
+ */
26
+ const axios_1 = __importDefault(require("axios"));
27
+ const child_process_1 = require("child_process");
28
+ const fs_1 = __importDefault(require("fs"));
29
+ const path_1 = __importDefault(require("path"));
30
+ const pack_home_1 = require("./pack-home");
31
+ exports.ALLOWED_GIT_URL = /^(https?:\/\/|ssh:\/\/|git:\/\/|file:\/\/|[\w.-]+@[\w.-]+:)/;
32
+ /** Arg-array git, never a shell string, so a path or URL cannot be reinterpreted. */
33
+ function git(args, cwd, timeout = 60_000) {
34
+ return (0, child_process_1.execFileSync)('git', args, {
35
+ cwd,
36
+ encoding: 'utf8',
37
+ stdio: ['ignore', 'pipe', 'pipe'],
38
+ timeout,
39
+ }).toString().trim();
40
+ }
41
+ /**
42
+ * Committer identity args for a pack commit.
43
+ *
44
+ * FRAIM commits on the user's behalf into a pack repo. If the machine has no
45
+ * git identity configured, `git commit` fails outright with "Author identity
46
+ * unknown" — a fresh laptop, a container, or CI all hit this. Prefer whatever
47
+ * identity the user has configured, and fall back to a FRAIM one so publishing
48
+ * never depends on unrelated global git setup.
49
+ */
50
+ function committerArgs(cwd) {
51
+ const configured = (key) => {
52
+ try {
53
+ return git(['config', '--get', key], cwd).length > 0;
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ };
59
+ if (configured('user.name') && configured('user.email'))
60
+ return [];
61
+ return ['-c', 'user.name=FRAIM', '-c', 'user.email=fraim@users.noreply.github.com'];
62
+ }
63
+ /**
64
+ * Remove any userinfo (`user:token@`) from a URL before it is shown or recorded.
65
+ *
66
+ * A remote configured as `https://x-access-token:<pat>@github.com/o/r.git` is a
67
+ * common pattern. Every string this module hands back is printed by the CLI and
68
+ * recorded into share-with-others run evidence, so an un-redacted URL would
69
+ * write a live credential into a durable artifact.
70
+ */
71
+ function redactGitUrl(gitUrl) {
72
+ return gitUrl.replace(/^([a-z+]+:\/\/)[^/@]*@/i, '$1');
73
+ }
74
+ /** Convert a git remote URL to its https browse form, or undefined. */
75
+ function httpsBrowseUrl(gitUrl) {
76
+ // Strip a trailing slash too: without it a remote ending in "/" produced a
77
+ // double slash in the compare URL (".../o/r//compare/b?expand=1").
78
+ const httpUrl = redactGitUrl(gitUrl)
79
+ .replace(/\.git$/, '')
80
+ .replace(/^git@([^:]+):/, 'https://$1/')
81
+ .replace(/\/+$/, '');
82
+ return /^https?:\/\//.test(httpUrl) ? httpUrl : undefined;
83
+ }
84
+ /** The "open a PR by hand" link. Used only as a fallback when no PR was created. */
85
+ function gitCompareUrl(gitUrl, branch) {
86
+ const browse = httpsBrowseUrl(gitUrl);
87
+ return browse ? `${browse}/compare/${branch}?expand=1` : undefined;
88
+ }
89
+ /** Extract { owner, repo } from a GitHub remote URL, or null when it is not GitHub. */
90
+ function parseGitHubSlug(gitUrl) {
91
+ const browse = httpsBrowseUrl(gitUrl);
92
+ if (!browse)
93
+ return null;
94
+ const match = /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+?)\/?$/.exec(browse);
95
+ if (!match)
96
+ return null;
97
+ return { owner: match[1], repo: match[2] };
98
+ }
99
+ /**
100
+ * Default opener: GitHub REST, authenticated with GITHUB_TOKEN.
101
+ *
102
+ * Mirrors the request shape already used by src/fraim/issues.ts so both paths
103
+ * authenticate the same way. Resolves null (rather than throwing) when the
104
+ * remote is not GitHub or no token is present, so the caller reports a branch
105
+ * plus a reason instead of losing the push that already succeeded.
106
+ */
107
+ const openGitHubPullRequest = async (input) => {
108
+ const slug = parseGitHubSlug(input.gitUrl);
109
+ if (!slug)
110
+ return null;
111
+ const token = process.env.GITHUB_TOKEN;
112
+ if (!token)
113
+ return null;
114
+ // GITHUB_API_URL is the standard override (GitHub Actions sets it, and it is
115
+ // what GitHub Enterprise installs need). Defaults to public GitHub.
116
+ const apiBase = (process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/$/, '');
117
+ const response = await axios_1.default.post(`${apiBase}/repos/${slug.owner}/${slug.repo}/pulls`, { title: input.title, body: input.body, head: input.branch, base: input.baseBranch }, {
118
+ headers: {
119
+ Authorization: `Bearer ${token}`,
120
+ Accept: 'application/vnd.github.v3+json',
121
+ 'Content-Type': 'application/json',
122
+ },
123
+ timeout: 30_000,
124
+ });
125
+ return { url: String(response.data?.html_url ?? ''), number: Number(response.data?.number ?? 0) };
126
+ };
127
+ exports.openGitHubPullRequest = openGitHubPullRequest;
128
+ /**
129
+ * Build the commit subject, PR title, and PR body for a pack change.
130
+ *
131
+ * Shared so the manager and org publishers describe a change identically. Both
132
+ * sides had their own copy of this, which is the duplication this module exists
133
+ * to remove.
134
+ */
135
+ function describePackChange(packLabel, artifacts, deletions) {
136
+ const parts = [`${artifacts.length} added or updated`];
137
+ if (deletions.length > 0)
138
+ parts.push(`${deletions.length} removed`);
139
+ const summary = parts.join(', ');
140
+ const lines = [`Published by FRAIM to the ${packLabel} pack.`, ''];
141
+ if (artifacts.length > 0) {
142
+ lines.push('**Added or updated**');
143
+ for (const a of artifacts)
144
+ lines.push(`- \`${a.relativePath}\``);
145
+ lines.push('');
146
+ }
147
+ if (deletions.length > 0) {
148
+ lines.push('**Removed** (the source side of a move, in the same commit)');
149
+ for (const d of deletions)
150
+ lines.push(`- \`${d}\``);
151
+ lines.push('');
152
+ }
153
+ const title = `Update ${packLabel} pack: ${summary}`;
154
+ return { summary, title, body: lines.join('\n') };
155
+ }
156
+ /** Why the default opener declined, so the caller can say something useful. */
157
+ function defaultBlockedReason(gitUrl) {
158
+ if (!parseGitHubSlug(gitUrl)) {
159
+ return `The pack remote is not a GitHub repository, so no pull request could be opened automatically. Open one manually against ${redactGitUrl(gitUrl)}.`;
160
+ }
161
+ return 'No GITHUB_TOKEN is set, so no pull request could be opened automatically. Set GITHUB_TOKEN and re-run, or open the pull request from the compare link.';
162
+ }
163
+ /**
164
+ * Publish from the layer's durable local clone (issue #1043 review round 1).
165
+ *
166
+ * The clone is a working copy that behaves like a project checkout, not a
167
+ * throwaway `mkdtemp` snapshot. That has a consequence worth stating: the
168
+ * user's content lives in this directory, so publishing must never leave the
169
+ * working tree switched to a feature branch or emptied while a PR is open.
170
+ *
171
+ * So the commit is made on whatever branch the copy is already on, and that
172
+ * commit is pushed to a *new remote ref* with `push origin HEAD:refs/heads/<b>`.
173
+ * The working copy keeps the content, stays on its branch, and a later
174
+ * fast-forward after the PR merges is a no-op.
175
+ *
176
+ * A push that succeeds is never reported as a failure just because the PR could
177
+ * not be opened: the branch is always returned so the change is recoverable.
178
+ */
179
+ async function publishPackToGitRepo(opts) {
180
+ const dir = opts.cloneDir ?? (0, pack_home_1.defaultCloneDir)('manager');
181
+ const clone = (0, pack_home_1.ensurePackClone)(opts.gitUrl, dir);
182
+ if (clone.sha === null) {
183
+ throw new Error(clone.offlineReason ?? 'The pack repo clone is not available.');
184
+ }
185
+ const baseBranch = git(['rev-parse', '--abbrev-ref', 'HEAD'], dir);
186
+ const branch = `${opts.branchPrefix}${opts.branchSuffix || `${process.pid}-${Date.now()}`}`;
187
+ const written = new Set();
188
+ const touched = [];
189
+ for (const artifact of opts.artifacts) {
190
+ const dest = path_1.default.join(dir, artifact.relativePath);
191
+ fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
192
+ fs_1.default.writeFileSync(dest, artifact.content);
193
+ written.add(artifact.relativePath);
194
+ touched.push(artifact.relativePath);
195
+ }
196
+ // The source side of a move, in the same commit as the write. A path that
197
+ // is also a write target is skipped: a same-path move is a no-op, not a
198
+ // delete of what was just written.
199
+ for (const relativePath of opts.deletions ?? []) {
200
+ if (written.has(relativePath))
201
+ continue;
202
+ const target = path_1.default.join(dir, relativePath);
203
+ if (!fs_1.default.existsSync(target))
204
+ continue;
205
+ fs_1.default.rmSync(target, { force: true });
206
+ touched.push(relativePath);
207
+ }
208
+ // Stage only the paths this publish touched. The clone is a working copy the
209
+ // user may have other edits in; `git add -A` would sweep them into the PR.
210
+ if (touched.length > 0)
211
+ git(['add', '--', ...touched], dir);
212
+ const staged = git(['diff', '--cached', '--name-only'], dir);
213
+ if (staged.length > 0) {
214
+ git([...committerArgs(dir), 'commit', '-m', opts.commitMessage], dir);
215
+ }
216
+ git(['push', '--quiet', 'origin', `HEAD:refs/heads/${branch}`], dir);
217
+ // The push landed. From here nothing may throw the branch away.
218
+ const opener = opts.openPullRequest ?? exports.openGitHubPullRequest;
219
+ try {
220
+ const pr = await opener({
221
+ gitUrl: opts.gitUrl,
222
+ branch,
223
+ baseBranch,
224
+ title: opts.prTitle,
225
+ body: opts.prBody,
226
+ });
227
+ if (pr?.url)
228
+ return { branch, baseBranch, prUrl: pr.url, prCreated: true, credentialWarning: clone.credentialWarning };
229
+ return {
230
+ branch,
231
+ baseBranch,
232
+ prUrl: gitCompareUrl(opts.gitUrl, branch),
233
+ prCreated: false,
234
+ prBlockedReason: defaultBlockedReason(opts.gitUrl),
235
+ credentialWarning: clone.credentialWarning,
236
+ };
237
+ }
238
+ catch (error) {
239
+ return {
240
+ branch,
241
+ baseBranch,
242
+ prUrl: gitCompareUrl(opts.gitUrl, branch),
243
+ prCreated: false,
244
+ prBlockedReason: `The branch was pushed but opening the pull request failed: ${error?.message ?? String(error)}`,
245
+ credentialWarning: clone.credentialWarning,
246
+ };
247
+ }
248
+ }
@@ -0,0 +1,279 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.packsDir = packsDir;
7
+ exports.defaultCloneDir = defaultCloneDir;
8
+ exports.resolvePackHome = resolvePackHome;
9
+ exports.packReadRoots = packReadRoots;
10
+ exports.findStrandedLegacyContent = findStrandedLegacyContent;
11
+ exports.gitUrlHasUserinfo = gitUrlHasUserinfo;
12
+ exports.ensurePackClone = ensurePackClone;
13
+ /**
14
+ * Where a layer's details live (issue #1043, review rounds 1 and 2).
15
+ *
16
+ * **Everything is a home. There are no caches.** Each layer has exactly one
17
+ * place, named by config, and that place holds the whole layer: context, rules,
18
+ * learnings, jobs, skills, templates, and employees. Some homes are synced to
19
+ * other machines, some are local only, but it is always the single place.
20
+ *
21
+ * single-machine -> the standard local path; local only, nothing to publish
22
+ * local-folder -> the configured folder; the OS sync client replicates it
23
+ * git -> a durable local clone that behaves like a project checkout
24
+ * fraim-cloud -> a managed local directory the API syncs to and from
25
+ *
26
+ * This replaced an earlier model in which the manager layer had three locations
27
+ * (a writable home, a second copy at `~/.fraim/manager/`, and the real storage
28
+ * behind the backend) and the org layer had two with no writable home at all,
29
+ * which is why an org-level write used to be refused outright. Round 1 collapsed
30
+ * that to home-plus-cache; round 2 removed the cache. A read now has exactly one
31
+ * answer, so no copy can silently diverge from another.
32
+ */
33
+ const child_process_1 = require("child_process");
34
+ const fs_1 = __importDefault(require("fs"));
35
+ const path_1 = __importDefault(require("path"));
36
+ const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
37
+ const user_config_1 = require("./user-config");
38
+ /**
39
+ * Make a user-supplied path deterministic.
40
+ *
41
+ * A relative localPath would otherwise resolve against process.cwd(), so the
42
+ * same config would point at different directories depending on where the CLI
43
+ * or the Hub server happened to start. Anchor it to the user FRAIM dir instead.
44
+ */
45
+ function absolutize(p) {
46
+ return path_1.default.isAbsolute(p) ? p : path_1.default.resolve((0, project_fraim_paths_1.getUserFraimDirPath)(), p);
47
+ }
48
+ /** FRAIM-managed directory for pack copies that have no user-chosen location. */
49
+ function packsDir() {
50
+ return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'packs');
51
+ }
52
+ /**
53
+ * The standard local home, used when the layer is not synced anywhere.
54
+ *
55
+ * Both layers keep their historical paths so existing machines need no
56
+ * migration. Inventing a new location here would orphan content that is
57
+ * already on disk: `~/.fraim/org/` has always been where org content lives,
58
+ * and `~/.fraim/personalized-employee/` is where a manager authors.
59
+ */
60
+ function standardHome(layer) {
61
+ if (layer === 'manager')
62
+ return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee');
63
+ return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'org');
64
+ }
65
+ /**
66
+ * Where the cloud backend materializes. Also the historical path for each
67
+ * layer, so switching to or from `fraim-cloud` does not relocate anything.
68
+ */
69
+ function cloudHome(layer) {
70
+ return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), layer === 'org' ? 'org' : 'manager');
71
+ }
72
+ /** Default clone directory when a git backend does not name one. */
73
+ function defaultCloneDir(layer) {
74
+ return path_1.default.join(packsDir(), `${layer}-repo`);
75
+ }
76
+ /**
77
+ * Resolve where this layer's details live. Never throws and never touches the
78
+ * network; call `ensurePackClone` separately when the git working copy has to
79
+ * be present and current.
80
+ */
81
+ function resolvePackHome(layer) {
82
+ const config = (0, user_config_1.getLayerBackend)(layer);
83
+ const standard = standardHome(layer);
84
+ if (!config || config.backend === 'single-machine') {
85
+ return {
86
+ layer,
87
+ backend: config?.backend ?? 'single-machine',
88
+ root: standard,
89
+ synced: false,
90
+ requiresPublish: false,
91
+ };
92
+ }
93
+ if (config.backend === 'local-folder' && config.localPath) {
94
+ return {
95
+ layer,
96
+ backend: 'local-folder',
97
+ root: absolutize(config.localPath),
98
+ synced: true,
99
+ // The sync client replicates the write; there is nothing further to do.
100
+ requiresPublish: false,
101
+ legacyRoot: standard,
102
+ };
103
+ }
104
+ if (config.backend === 'git' && config.gitUrl) {
105
+ return {
106
+ layer,
107
+ backend: 'git',
108
+ root: config.localPath ? absolutize(config.localPath) : defaultCloneDir(layer),
109
+ synced: true,
110
+ requiresPublish: true,
111
+ gitUrl: config.gitUrl,
112
+ legacyRoot: standard,
113
+ };
114
+ }
115
+ if (config.backend === 'fraim-cloud') {
116
+ return {
117
+ layer,
118
+ backend: 'fraim-cloud',
119
+ root: cloudHome(layer),
120
+ synced: true,
121
+ requiresPublish: true,
122
+ legacyRoot: standard,
123
+ };
124
+ }
125
+ // A backend named but missing its required pointer (git without gitUrl,
126
+ // local-folder without localPath). Fall back to the standard home rather
127
+ // than inventing a location.
128
+ return {
129
+ layer,
130
+ backend: config.backend,
131
+ root: standard,
132
+ synced: false,
133
+ requiresPublish: false,
134
+ };
135
+ }
136
+ /**
137
+ * Where to read this layer from. Exactly one directory: the home.
138
+ *
139
+ * Kept as an array because callers iterate it, and because narrowing from three
140
+ * roots to one was the point of round 2. If this ever returns more than one
141
+ * entry again, the single-place invariant has been lost.
142
+ */
143
+ function packReadRoots(layer) {
144
+ return [resolvePackHome(layer).root];
145
+ }
146
+ /**
147
+ * Content sitting at the standard local path that the configured home does not
148
+ * hold. Since the home is the single read location, this content is stranded:
149
+ * it still exists on disk but nothing reads it.
150
+ *
151
+ * Returned so callers can *tell the user*, rather than silently orphaning work
152
+ * authored before a synced backend was configured. Nothing is moved or deleted
153
+ * here; relocating is a deliberate act, following the `org-migration.ts`
154
+ * precedent of reporting and archiving rather than removing.
155
+ */
156
+ function findStrandedLegacyContent(layer) {
157
+ const home = resolvePackHome(layer);
158
+ if (!home.legacyRoot || home.legacyRoot === home.root)
159
+ return [];
160
+ if (!fs_1.default.existsSync(home.legacyRoot))
161
+ return [];
162
+ const stranded = [];
163
+ const walk = (absDir, relDir) => {
164
+ let entries;
165
+ try {
166
+ entries = fs_1.default.readdirSync(absDir, { withFileTypes: true });
167
+ }
168
+ catch {
169
+ return;
170
+ }
171
+ for (const entry of entries) {
172
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
173
+ const abs = path_1.default.join(absDir, entry.name);
174
+ if (entry.isDirectory())
175
+ walk(abs, rel);
176
+ else if (entry.isFile() && !fs_1.default.existsSync(path_1.default.join(home.root, rel)))
177
+ stranded.push(rel);
178
+ }
179
+ };
180
+ walk(home.legacyRoot, '');
181
+ return stranded.sort();
182
+ }
183
+ const ALLOWED_GIT_URL = /^(https?:\/\/|ssh:\/\/|git:\/\/|file:\/\/|[\w.-]+@[\w.-]+:)/;
184
+ /** True when the URL embeds `user:password@` or `user@` before the host. */
185
+ function gitUrlHasUserinfo(gitUrl) {
186
+ return /^[a-z+]+:\/\/[^/@]+@/i.test(gitUrl);
187
+ }
188
+ function credentialWarningFor(gitUrl) {
189
+ if (!gitUrlHasUserinfo(gitUrl))
190
+ return undefined;
191
+ return ('The configured pack remote embeds credentials in its URL. git stores the remote verbatim in the ' +
192
+ 'clone .git/config, and this clone is durable, so that credential now sits on disk at a predictable path. ' +
193
+ 'Use a credential helper or SSH key and remove the userinfo from gitUrl in ~/.fraim/config.json.');
194
+ }
195
+ function isGitWorkTree(dir) {
196
+ return fs_1.default.existsSync(path_1.default.join(dir, '.git'));
197
+ }
198
+ function git(args, cwd, timeout = 60_000) {
199
+ return (0, child_process_1.execFileSync)('git', args, {
200
+ cwd,
201
+ encoding: 'utf8',
202
+ stdio: ['ignore', 'pipe', 'pipe'],
203
+ timeout,
204
+ }).toString().trim();
205
+ }
206
+ /**
207
+ * Make the git pack home present and as current as the network allows.
208
+ *
209
+ * This is the "works like a project" behavior: clone once into a durable
210
+ * directory, then refresh with fetch plus a fast-forward-only merge. A
211
+ * fast-forward-only merge is deliberate. It can refuse, and refusing is
212
+ * correct: local commits that have not been pushed must never be silently
213
+ * rebased or merged away.
214
+ *
215
+ * Network failure is not an error. The existing local copy is returned with
216
+ * `offlineReason` set, so reads keep working on a plane.
217
+ */
218
+ function ensurePackClone(gitUrl, dir) {
219
+ if (!ALLOWED_GIT_URL.test(gitUrl)) {
220
+ const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(gitUrl)?.[1] ?? '(none)';
221
+ throw new Error(`Pack repo URL has an unsupported scheme '${scheme}'. Expected https, ssh, git, file, or scp-style user@host:path.`);
222
+ }
223
+ if (!isGitWorkTree(dir)) {
224
+ // Never clear a non-empty directory to make room for the clone. The
225
+ // clone target is a user-supplied path; if they point it at a folder
226
+ // that already holds something, wiping it would destroy their data over
227
+ // a config typo. Refuse and say exactly what to do.
228
+ if (fs_1.default.existsSync(dir) && fs_1.default.readdirSync(dir).length > 0) {
229
+ throw new Error(`Cannot use '${dir}' as the pack clone: it already contains files and is not a git repository. ` +
230
+ `Point localPath at an empty or non-existent directory, or clone the pack repo there yourself.`);
231
+ }
232
+ fs_1.default.mkdirSync(dir, { recursive: true });
233
+ try {
234
+ // Full clone, not depth-1: this is a working copy, not a snapshot.
235
+ git(['clone', '--quiet', '--', gitUrl, '.'], dir);
236
+ return { dir, sha: git(['rev-parse', 'HEAD'], dir), created: true, updated: false, credentialWarning: credentialWarningFor(gitUrl) };
237
+ }
238
+ catch (error) {
239
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
240
+ return {
241
+ dir,
242
+ sha: null,
243
+ created: false,
244
+ updated: false,
245
+ offlineReason: `Could not clone the pack repo: ${error?.message ?? String(error)}`,
246
+ };
247
+ }
248
+ }
249
+ const before = (() => {
250
+ try {
251
+ return git(['rev-parse', 'HEAD'], dir);
252
+ }
253
+ catch {
254
+ return null;
255
+ }
256
+ })();
257
+ try {
258
+ git(['fetch', '--quiet', 'origin'], dir);
259
+ git(['merge', '--ff-only', '--quiet', '@{u}'], dir);
260
+ }
261
+ catch (error) {
262
+ return {
263
+ dir,
264
+ sha: before,
265
+ created: false,
266
+ updated: false,
267
+ offlineReason: `Using the local copy; could not fast-forward from the remote: ${error?.message ?? String(error)}`,
268
+ };
269
+ }
270
+ const after = (() => {
271
+ try {
272
+ return git(['rev-parse', 'HEAD'], dir);
273
+ }
274
+ catch {
275
+ return before;
276
+ }
277
+ })();
278
+ return { dir, sha: after, created: false, updated: after !== before, credentialWarning: credentialWarningFor(gitUrl) };
279
+ }
@@ -91,7 +91,11 @@ function getOrganizationConfig() {
91
91
  const gitUrl = typeof raw.gitUrl === 'string' ? raw.gitUrl.trim() : '';
92
92
  if (!gitUrl)
93
93
  return null;
94
- return { backend: 'git', gitUrl, id: raw.id };
94
+ // Issue #1043: a git backend also carries where the durable clone lives.
95
+ // Dropping localPath here made the pointer unusable and silently forced
96
+ // the default clone directory.
97
+ const localPath = typeof raw.localPath === 'string' ? raw.localPath.trim() : '';
98
+ return { backend: 'git', gitUrl, id: raw.id, ...(localPath ? { localPath } : {}) };
95
99
  }
96
100
  if (raw.backend === 'fraim-cloud') {
97
101
  return { backend: 'fraim-cloud', id: raw.id };
@@ -119,7 +123,11 @@ function getManagerStorageConfig() {
119
123
  const gitUrl = typeof raw.gitUrl === 'string' ? raw.gitUrl.trim() : '';
120
124
  if (!gitUrl)
121
125
  return null;
122
- return { backend: 'git', gitUrl, id: raw.id };
126
+ // Issue #1043: a git backend also carries where the durable clone lives.
127
+ // Dropping localPath here made the pointer unusable and silently forced
128
+ // the default clone directory.
129
+ const localPath = typeof raw.localPath === 'string' ? raw.localPath.trim() : '';
130
+ return { backend: 'git', gitUrl, id: raw.id, ...(localPath ? { localPath } : {}) };
123
131
  }
124
132
  if (raw.backend === 'fraim-cloud') {
125
133
  return { backend: 'fraim-cloud', id: raw.id };
@@ -9,13 +9,20 @@
9
9
  * Phase-1 scope: jobs, skills, rules/<category>/…, templates, scripts.
10
10
  * (Scripts are accepted by isCapabilityPackPath so the guard is correct from
11
11
  * Phase 1; the server-side security gate for scripts is Phase 3 work.)
12
+ *
13
+ * Issue #1043 adds `employees`. A custom employee record was the one
14
+ * personalization type with no pack representation, so it could not be
15
+ * published or synced by any backend and share-with-others could never move
16
+ * one between levels. Making it a pack member reuses the publish validator,
17
+ * the sync materializer, and the resolver overlay guard, which is the whole
18
+ * point of this module being the single allowlist.
12
19
  */
13
20
  Object.defineProperty(exports, "__esModule", { value: true });
14
21
  exports.CAPABILITY_DIRS = void 0;
15
22
  exports.isCapabilityPackPath = isCapabilityPackPath;
16
23
  exports.isScriptPath = isScriptPath;
17
24
  exports.shouldDecorateAsMarkdown = shouldDecorateAsMarkdown;
18
- exports.CAPABILITY_DIRS = ['jobs', 'skills', 'rules', 'templates', 'scripts'];
25
+ exports.CAPABILITY_DIRS = ['jobs', 'skills', 'rules', 'templates', 'scripts', 'employees'];
19
26
  /**
20
27
  * Returns true when `rel` is a valid capability-pack member path:
21
28
  *
@@ -24,6 +31,7 @@ exports.CAPABILITY_DIRS = ['jobs', 'skills', 'rules', 'templates', 'scripts'];
24
31
  * templates/<category>/<name>.md
25
32
  * rules/<category>/<name>.md (depth ≥ 2 — flat rules/<name>.md is reserved)
26
33
  * scripts/<name>.<ext>
34
+ * employees/<slug>.json (flat — employees have no category)
27
35
  *
28
36
  * Rejects: path traversal (../ or \), absolute paths (/), control characters,
29
37
  * flat rules/<name>.md (reserved for org_rules.md / manager_rules.md).
@@ -56,6 +64,20 @@ function isCapabilityPackPath(rel) {
56
64
  const name = rest[0];
57
65
  return name.length > 0 && /\.[a-zA-Z]+$/.test(name);
58
66
  }
67
+ if (dir === 'employees') {
68
+ // employees/<slug>.json — flat, like scripts. Employees have no category
69
+ // dimension: custom-employees.ts resolves them by bare slug, so a
70
+ // categorized path would not round-trip back to a readable record.
71
+ if (rest.length !== 1)
72
+ return false;
73
+ const name = rest[0];
74
+ if (!name.endsWith('.json'))
75
+ return false;
76
+ // The slug must be a real name. Requiring an alphanumeric rejects
77
+ // degenerate filenames like "..json" (slug ".") that pass a bare
78
+ // length check but name no employee.
79
+ return /[A-Za-z0-9]/.test(name.slice(0, -'.json'.length));
80
+ }
59
81
  if (dir === 'rules') {
60
82
  // rules/<category>/<name>.md — depth exactly 2 (no flat rules/)
61
83
  if (rest.length !== 2)
@@ -15,7 +15,28 @@ exports.MANAGER_PACK_RELATIVE_PATH_RE = /^(context\/manager_context\.md|rules\/m
15
15
  function isManagerPackRelativePath(value) {
16
16
  return exports.MANAGER_PACK_RELATIVE_PATH_RE.test(value) || (0, capability_pack_1.isCapabilityPackPath)(value);
17
17
  }
18
+ /**
19
+ * Infer the manager-pack location for a local file by its path or name:
20
+ * manager_context.md -> context/manager_context.md
21
+ * manager_rules.md -> rules/manager_rules.md
22
+ * <user>-<family>.md -> learnings/<user>-<family>.md
23
+ * jobs/<cat>/<name>.md | skills/<cat>/<name>.md | rules/<cat>/<name>.md |
24
+ * templates/<cat>/<name>.md | scripts/<name>.<ext> | employees/<slug>.json
25
+ * -> capability path returned as-is
26
+ *
27
+ * Issue #1043: the capability branch was previously missing here even though
28
+ * isManagerPackRelativePath already ACCEPTED capability paths. An accepting
29
+ * validator paired with a rejecting resolver meant `fraim manager publish`
30
+ * threw for every job, skill, nested rule, and employee. This mirrors
31
+ * org-publish.packRelativePathFor, which has handled it since issue #869
32
+ * Phase 2, so both packs now resolve capability paths identically.
33
+ */
18
34
  function managerPackRelativePathForFileName(fileName) {
35
+ // Normalize separators before the allowlist check: isCapabilityPackPath
36
+ // rejects backslashes outright, and on Windows the CLI receives them.
37
+ const normalized = fileName.replace(/\\/g, '/').replace(/^\/+/, '');
38
+ if ((0, capability_pack_1.isCapabilityPackPath)(normalized))
39
+ return normalized;
19
40
  const base = path_1.default.basename(fileName);
20
41
  if (base === 'manager_context.md')
21
42
  return exports.MANAGER_CONTEXT_RELATIVE_PATH;
@@ -23,10 +44,5 @@ function managerPackRelativePathForFileName(fileName) {
23
44
  return exports.MANAGER_RULES_RELATIVE_PATH;
24
45
  if (exports.MANAGER_LEARNING_FILE_RE.test(base))
25
46
  return `learnings/${base}`;
26
- // Capability files carry their full relative path as-is (e.g. jobs/cat/name.md).
27
- // callers that have the full relative path should pass it directly to
28
- // isManagerPackRelativePath; this filename-only lookup cannot reconstruct the
29
- // category from a bare basename, so return null and let the caller supply the
30
- // full path.
31
47
  return null;
32
48
  }