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.
- package/dist/src/cli/commands/manager.js +26 -7
- package/dist/src/cli/commands/org.js +26 -7
- package/dist/src/cli/commands/sync.js +34 -0
- package/dist/src/cli/utils/local-folder-sync.js +100 -48
- package/dist/src/cli/utils/manager-pack-sync.js +77 -40
- package/dist/src/cli/utils/manager-publish.js +38 -40
- package/dist/src/cli/utils/org-pack-sync.js +80 -41
- package/dist/src/cli/utils/org-publish.js +37 -40
- package/dist/src/cli/utils/pack-git-publish.js +248 -0
- package/dist/src/cli/utils/pack-home.js +279 -0
- package/dist/src/cli/utils/user-config.js +10 -2
- package/dist/src/core/capability-pack.js +23 -1
- package/dist/src/core/manager-pack.js +21 -5
- package/dist/src/local-mcp-server/learning-context-builder.js +29 -8
- package/dist/src/local-mcp-server/stdio-server.js +12 -4
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|