filegrc 0.3.3 → 0.3.4
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/README.md +2 -2
- package/model/v1.json +8 -5
- package/package.json +1 -1
- package/src/cli.js +8 -3
- package/src/git-name.js +16 -0
- package/src/git.js +499 -1
- package/src/index.js +4 -1
- package/src/mutation.js +4 -1
- package/src/server.js +87 -19
- package/src/state.js +7 -2
- package/src/validate.js +2 -0
- package/src/web.js +84 -16
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ npx filegrc prepare-audit audit-id
|
|
|
34
34
|
npx filegrc evidence-packet --start 2026-01-01 --end 2026-06-30 --audit audit-id
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
`filegrc serve --help` prints bind, port, environment, and safety options without starting the server. The editable server defaults to `127.0.0.1:8787`; set `FILEGRC_HOST`, `FILEGRC_PORT`, or the matching flags when needed.
|
|
37
|
+
`filegrc serve --help` prints bind, port, environment, and safety options without starting the server. The editable server defaults to `127.0.0.1:8787`; set `FILEGRC_HOST`, `FILEGRC_PORT`, or the matching flags when needed. In trunk mode, browser saves synchronize, commit, and push from the authoritative branch. Use `--allow-non-authoritative-writes` for local development in a task checkout; the override never commits or pushes.
|
|
38
38
|
|
|
39
39
|
`filegrc setup` provides the headless equivalent of browser onboarding. Run it without arguments for guided terminal setup, or pass all initial service-boundary fields and a management program goal as flags or a JSON payload. Add `--preview` to validate and inspect the planned service and workspace writes without saving. Add `--summary --json` for compact agent output. Selecting Type 1 or Type 2 updates the workspace goal and selected systems. Setup does not select framework records, link controls, create evidence, or create an audit record.
|
|
40
40
|
|
|
@@ -54,7 +54,7 @@ Use `filegrc attach <evidence-id> <source-file>` to copy a fixed evidence file u
|
|
|
54
54
|
|
|
55
55
|
Use `filegrc detach <evidence-id> <attachment-name> --yes` for explicit removal. Evidence records with linked local attachments cannot be deleted.
|
|
56
56
|
|
|
57
|
-
The package requires Node.js 20 or newer. It uses Git for authors, commit timestamps, messages, diffs, and revisions.
|
|
57
|
+
The package requires Node.js 20 or newer. It uses Git for authors, commit timestamps, messages, diffs, and revisions. New workspaces use trunk mode, which fetches and fast-forwards before each browser mutation, validates and commits the saved change, then pushes. Existing settings without `repositoryMode` keep manual browser Git behavior. Agents and terminal users use Git directly; the filegrc CLI does not wrap pull, commit, or push.
|
|
58
58
|
|
|
59
59
|
The editable server has no authentication and binds to loopback by default. Put it behind trusted authentication before exposing it on a network, or publish the read-only static build.
|
|
60
60
|
|
package/model/v1.json
CHANGED
|
@@ -104,17 +104,20 @@
|
|
|
104
104
|
"pluralTitle": "Renderer settings",
|
|
105
105
|
"group": "repository",
|
|
106
106
|
"singleton": "renderer.json",
|
|
107
|
-
"description": "Optional local interface settings, including onboarding visibility
|
|
107
|
+
"description": "Optional local interface and browser repository settings, including onboarding visibility, manually completed Step pages, and authoritative-branch synchronization. Renderer settings are not required for SOC 2 and do not change compliance records.",
|
|
108
108
|
"guidance": {
|
|
109
|
-
"policyBasis": "Renderer settings are a filegrc convenience, not a SOC 2 requirement, control, audit record, or substitute for evidence.",
|
|
110
|
-
"cadence": "Change it when the team wants to rerun or suppress an optional renderer workflow
|
|
109
|
+
"policyBasis": "Renderer settings are a filegrc convenience, not a SOC 2 requirement, control, audit record, or substitute for evidence. Record lifecycle status represents approval; Git branches do not.",
|
|
110
|
+
"cadence": "Change it when the team wants to rerun or suppress an optional renderer workflow or change repository synchronization. Existing workspaces without repositoryMode keep manual browser Git behavior."
|
|
111
111
|
},
|
|
112
112
|
"required": ["showOnboarding"],
|
|
113
113
|
"fields": {
|
|
114
114
|
"showOnboarding": {"type":"boolean","label":"Show onboarding"},
|
|
115
|
-
"completedStagePageIds": {"type":"array","items":"string","label":"Manually completed program pages"}
|
|
115
|
+
"completedStagePageIds": {"type":"array","items":"string","label":"Manually completed program pages"},
|
|
116
|
+
"repositoryMode": {"type":"enum","values":["trunk","manual"],"label":"Repository mode"},
|
|
117
|
+
"authoritativeBranch": {"type":"string","format":"git-name","label":"Authoritative branch (defaults to main)"},
|
|
118
|
+
"repositoryRemote": {"type":"string","format":"git-name","label":"Repository remote (defaults to origin)"}
|
|
116
119
|
},
|
|
117
|
-
"listFields": ["title","showOnboarding","completedStagePageIds"]
|
|
120
|
+
"listFields": ["title","showOnboarding","repositoryMode","authoritativeBranch","repositoryRemote","completedStagePageIds"]
|
|
118
121
|
},
|
|
119
122
|
"person": {
|
|
120
123
|
"title": "Person",
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -36,6 +36,7 @@ import { validateWorkspace } from "./validate.js";
|
|
|
36
36
|
import { loadWorkspace } from "./workspace.js";
|
|
37
37
|
|
|
38
38
|
const BOOLEAN_FLAGS = new Set([
|
|
39
|
+
"allow-non-authoritative-writes",
|
|
39
40
|
"check-docs",
|
|
40
41
|
"complete",
|
|
41
42
|
"current",
|
|
@@ -63,7 +64,8 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
63
64
|
if (command === "serve") {
|
|
64
65
|
const result = await serveWorkspace(positionals[0] ?? root, {
|
|
65
66
|
host: flags.host ?? process.env.FILEGRC_HOST,
|
|
66
|
-
port: flags.port ?? process.env.FILEGRC_PORT
|
|
67
|
+
port: flags.port ?? process.env.FILEGRC_PORT,
|
|
68
|
+
allowNonAuthoritativeWrites: flags["allow-non-authoritative-writes"] === true
|
|
67
69
|
});
|
|
68
70
|
const stopped = new Promise((resolvePromise) => {
|
|
69
71
|
const stop = () => {
|
|
@@ -683,7 +685,7 @@ function printHelp() {
|
|
|
683
685
|
console.log(`filegrc - Git-native GRC workspace
|
|
684
686
|
|
|
685
687
|
Usage:
|
|
686
|
-
filegrc serve [root] [--host 127.0.0.1] [--port 8787]
|
|
688
|
+
filegrc serve [root] [--host 127.0.0.1] [--port 8787] [--allow-non-authoritative-writes]
|
|
687
689
|
filegrc setup [setup.json|-] [setup options] [--draft] [--preview] [--summary] [--json]
|
|
688
690
|
filegrc build [root] [--output .filegrc/site]
|
|
689
691
|
filegrc validate [root] [--json]
|
|
@@ -720,12 +722,15 @@ All commands accept --root <workspace>. Writes never create Git commits.`);
|
|
|
720
722
|
function printCommandHelp(command) {
|
|
721
723
|
if (command === "serve") {
|
|
722
724
|
console.log(`Usage:
|
|
723
|
-
filegrc serve [root] [--host address] [--port number]
|
|
725
|
+
filegrc serve [root] [--host address] [--port number] [--allow-non-authoritative-writes]
|
|
724
726
|
|
|
725
727
|
Options:
|
|
726
728
|
--host <address> Bind address. Defaults to FILEGRC_HOST or 127.0.0.1.
|
|
727
729
|
--port <number> Port. Defaults to FILEGRC_PORT or 8787. Use 0 for an available port.
|
|
728
730
|
--root <path> Workspace path when no positional root is given.
|
|
731
|
+
--allow-non-authoritative-writes
|
|
732
|
+
Allow local browser writes from a task checkout. This explicit
|
|
733
|
+
development override never commits or pushes.
|
|
729
734
|
--help Show this help without starting the server.
|
|
730
735
|
|
|
731
736
|
Safety:
|
package/src/git-name.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function isSafeGitName(value) {
|
|
2
|
+
const segments = value.split("/");
|
|
3
|
+
return Boolean(value)
|
|
4
|
+
&& value !== "@"
|
|
5
|
+
&& value !== "HEAD"
|
|
6
|
+
&& !value.startsWith("-")
|
|
7
|
+
&& !value.includes("..")
|
|
8
|
+
&& !value.includes("@{")
|
|
9
|
+
&& !/[\s~^:?*[\]\\\u0000-\u001f\u007f]/.test(value)
|
|
10
|
+
&& segments.every((segment) => (
|
|
11
|
+
segment
|
|
12
|
+
&& !segment.startsWith(".")
|
|
13
|
+
&& !segment.endsWith(".")
|
|
14
|
+
&& !segment.endsWith(".lock")
|
|
15
|
+
));
|
|
16
|
+
}
|
package/src/git.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { relative, resolve, sep } from "node:path";
|
|
5
|
+
import { isSafeGitName } from "./git-name.js";
|
|
2
6
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
3
7
|
import { resolveWorkspaceRoot } from "./paths.js";
|
|
4
8
|
import { validateWorkspace } from "./validate.js";
|
|
9
|
+
import { loadWorkspace } from "./workspace.js";
|
|
10
|
+
|
|
11
|
+
const lastSuccessfulSynchronizations = new Map();
|
|
5
12
|
|
|
6
13
|
export function getGitSummary(input = process.cwd()) {
|
|
7
14
|
const root = resolveWorkspaceRoot(input);
|
|
@@ -120,6 +127,175 @@ export async function pushWorkspace(input = process.cwd()) {
|
|
|
120
127
|
return serializeWorkspaceMutation(input, pushWorkspaceUnlocked);
|
|
121
128
|
}
|
|
122
129
|
|
|
130
|
+
export async function getBrowserRepositoryState(input = process.cwd(), options = {}) {
|
|
131
|
+
const root = resolveWorkspaceRoot(input);
|
|
132
|
+
const config = await getRepositoryConfig(root);
|
|
133
|
+
const gitSummary = getGitSummary(root);
|
|
134
|
+
if (config.mode !== "trunk") {
|
|
135
|
+
return {
|
|
136
|
+
mode: "manual",
|
|
137
|
+
authoritativeBranch: config.authoritativeBranch,
|
|
138
|
+
remote: config.remote,
|
|
139
|
+
developmentOverride: false,
|
|
140
|
+
status: "manual",
|
|
141
|
+
label: "Manual Git",
|
|
142
|
+
writesAllowed: !options.readOnly,
|
|
143
|
+
currentCommit: gitSummary.commit,
|
|
144
|
+
upstreamCommit: null,
|
|
145
|
+
ahead: null,
|
|
146
|
+
behind: null,
|
|
147
|
+
pendingCommits: [],
|
|
148
|
+
pendingCommitsFilegrcOnly: null,
|
|
149
|
+
lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
|
|
150
|
+
message: "Browser writes stay local until a user commits and synchronizes them."
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const details = inspectTrunkRepository(root, config, gitSummary);
|
|
155
|
+
const developmentOverride = options.allowNonAuthoritativeWrites === true;
|
|
156
|
+
if (developmentOverride) {
|
|
157
|
+
return {
|
|
158
|
+
...details,
|
|
159
|
+
developmentOverride: true,
|
|
160
|
+
writesAllowed: !options.readOnly,
|
|
161
|
+
status: "not-synced",
|
|
162
|
+
label: "Not synced",
|
|
163
|
+
message: "Development override is active. Browser writes stay local and FileGRC will not commit or push them."
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
...details,
|
|
168
|
+
developmentOverride: false,
|
|
169
|
+
writesAllowed: !options.readOnly && details.writesAllowed
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function runBrowserMutation(input, options, task) {
|
|
174
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
175
|
+
const config = await getRepositoryConfig(root);
|
|
176
|
+
if (config.mode !== "trunk" || options?.allowNonAuthoritativeWrites === true) {
|
|
177
|
+
return task(root);
|
|
178
|
+
}
|
|
179
|
+
return runTrunkMutationUnlocked(root, config, options, task);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function retryBrowserSync(input = process.cwd(), options = {}) {
|
|
184
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
185
|
+
const config = await getRepositoryConfig(root);
|
|
186
|
+
if (config.mode !== "trunk") throw new Error("Retry sync is available only in trunk repository mode.");
|
|
187
|
+
if (options.allowNonAuthoritativeWrites === true) {
|
|
188
|
+
throw new Error("Retry sync is disabled while the development write override is active.");
|
|
189
|
+
}
|
|
190
|
+
const before = requireTrunkPreconditions(root, config, { allowAhead: true });
|
|
191
|
+
fetchConfiguredRemote(root, config.remote);
|
|
192
|
+
const synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
193
|
+
if (synchronized.behind > 0 && synchronized.ahead > 0) {
|
|
194
|
+
throw new Error("The authoritative branch has diverged from its upstream. FileGRC will not merge or rebase it. Reconcile the repository with Git, then reload.");
|
|
195
|
+
}
|
|
196
|
+
if (synchronized.behind > 0) {
|
|
197
|
+
fastForwardConfiguredBranch(root, synchronized.upstream);
|
|
198
|
+
}
|
|
199
|
+
const ready = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
200
|
+
if (ready.ahead > 0 && !ready.pendingCommitsFilegrcOnly) {
|
|
201
|
+
throw new Error("At least one commit ahead of upstream changes files outside this FileGRC workspace. FileGRC will not push it. Reconcile the repository with Git.");
|
|
202
|
+
}
|
|
203
|
+
if (ready.ahead > 0) pushConfiguredBranch(root, config);
|
|
204
|
+
const after = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
205
|
+
if (after.ahead !== 0 || after.behind !== 0) {
|
|
206
|
+
throw new Error("The authoritative branch is still not synchronized. Reload the repository state before trying again.");
|
|
207
|
+
}
|
|
208
|
+
const synchronizedAt = new Date().toISOString();
|
|
209
|
+
lastSuccessfulSynchronizations.set(root, synchronizedAt);
|
|
210
|
+
return {
|
|
211
|
+
commit: after.currentCommit,
|
|
212
|
+
shortCommit: after.currentCommit?.slice(0, 8) ?? null,
|
|
213
|
+
branch: config.authoritativeBranch,
|
|
214
|
+
upstream: after.upstream,
|
|
215
|
+
synchronizedAt,
|
|
216
|
+
retriedCommits: before.ahead ?? 0
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
222
|
+
requireTrunkPreconditions(root, config);
|
|
223
|
+
fetchConfiguredRemote(root, config.remote);
|
|
224
|
+
let synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
225
|
+
if (synchronized.ahead > 0 && synchronized.behind > 0) {
|
|
226
|
+
throw new Error("The authoritative branch has diverged from its upstream. FileGRC will not merge or rebase it. Reconcile the repository with Git, then reload.");
|
|
227
|
+
}
|
|
228
|
+
if (synchronized.ahead > 0) {
|
|
229
|
+
throw new Error("The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.");
|
|
230
|
+
}
|
|
231
|
+
if (synchronized.behind > 0) {
|
|
232
|
+
fastForwardConfiguredBranch(root, synchronized.upstream);
|
|
233
|
+
synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
234
|
+
}
|
|
235
|
+
if (synchronized.ahead !== 0 || synchronized.behind !== 0) {
|
|
236
|
+
throw new Error("The authoritative branch is not synchronized with its upstream. Reload after reconciling the repository with Git.");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let result;
|
|
240
|
+
let subject;
|
|
241
|
+
try {
|
|
242
|
+
result = await task(root);
|
|
243
|
+
subject = generatedCommitMessage(typeof options?.message === "function" ? options.message(result) : options?.message);
|
|
244
|
+
const validation = await validateWorkspace(root);
|
|
245
|
+
if (!validation.ok) {
|
|
246
|
+
throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. The browser change was rolled back.`);
|
|
247
|
+
}
|
|
248
|
+
assertNoOutsideWorktreeChanges(root);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
try {
|
|
251
|
+
await rollbackWorkspaceChanges(root);
|
|
252
|
+
} catch (rollbackError) {
|
|
253
|
+
throw new Error(`${error.message} FileGRC could not roll back the workspace change. ${rollbackError.message} Later browser mutations are blocked until the Git worktree is reconciled.`);
|
|
254
|
+
}
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!getGitSummary(root).changes.length) {
|
|
259
|
+
throw new Error("The browser action did not change any FileGRC workspace files.");
|
|
260
|
+
}
|
|
261
|
+
if (!tryGit(root, ["config", "user.name"]) || !tryGit(root, ["config", "user.email"])) {
|
|
262
|
+
throw new Error("Configure git user.name and git user.email before browser changes can be committed. The saved files remain uncommitted and later browser changes are blocked.");
|
|
263
|
+
}
|
|
264
|
+
gitForWrite(root, ["add", "--all", "--", "."], "stage the FileGRC workspace change");
|
|
265
|
+
assertNoOutsideWorktreeChanges(root, false);
|
|
266
|
+
assertOnlyWorkspaceFilesStaged(root);
|
|
267
|
+
try {
|
|
268
|
+
gitForWrite(root, ["commit", "-m", subject, "--", "."], "create the FileGRC browser commit");
|
|
269
|
+
} catch (error) {
|
|
270
|
+
throw new Error(`${error.message} The saved files remain in the Git worktree and later browser changes are blocked.`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const committed = getGitSummary(root);
|
|
274
|
+
let pushError = null;
|
|
275
|
+
try {
|
|
276
|
+
pushConfiguredBranch(root, config);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
pushError = `${error.message} The local FileGRC commit was retained. Use Retry sync after the remote is available.`;
|
|
279
|
+
}
|
|
280
|
+
const after = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
281
|
+
let synchronizedAt = null;
|
|
282
|
+
if (!pushError && after.ahead === 0 && after.behind === 0) {
|
|
283
|
+
synchronizedAt = new Date().toISOString();
|
|
284
|
+
lastSuccessfulSynchronizations.set(root, synchronizedAt);
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
...result,
|
|
288
|
+
synchronization: {
|
|
289
|
+
status: pushError ? "not-synced" : "synced",
|
|
290
|
+
commit: committed.commit,
|
|
291
|
+
shortCommit: committed.shortCommit,
|
|
292
|
+
upstream: after.upstream,
|
|
293
|
+
synchronizedAt,
|
|
294
|
+
pushError
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
123
299
|
async function commitWorkspaceUnlocked(root, message) {
|
|
124
300
|
const subject = String(message ?? "").trim();
|
|
125
301
|
if (!subject || subject.length > 200 || /[\u0000-\u001f\u007f]/.test(subject)) {
|
|
@@ -233,6 +409,299 @@ function syncReadySummary(root, action) {
|
|
|
233
409
|
return summary;
|
|
234
410
|
}
|
|
235
411
|
|
|
412
|
+
async function getRepositoryConfig(root) {
|
|
413
|
+
const loaded = await loadWorkspace(root);
|
|
414
|
+
const renderer = loaded.resources.find(({ type, id }) => type === "renderer-settings" && id === "renderer-settings");
|
|
415
|
+
const mode = renderer?.repositoryMode === "trunk" ? "trunk" : "manual";
|
|
416
|
+
const authoritativeBranch = cleanGitName(renderer?.authoritativeBranch, "main");
|
|
417
|
+
const remote = cleanGitName(renderer?.repositoryRemote, "origin");
|
|
418
|
+
return {
|
|
419
|
+
mode,
|
|
420
|
+
authoritativeBranch,
|
|
421
|
+
remote,
|
|
422
|
+
configurationError: !isSafeGitName(authoritativeBranch)
|
|
423
|
+
? "The configured authoritative branch is not a safe Git branch name. Update renderer settings before using browser writes."
|
|
424
|
+
: !isSafeGitName(remote)
|
|
425
|
+
? "The configured repository remote is not a safe Git remote name. Update renderer settings before using browser writes."
|
|
426
|
+
: null
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function inspectTrunkRepository(root, config, summary = getGitSummary(root)) {
|
|
431
|
+
const base = {
|
|
432
|
+
mode: "trunk",
|
|
433
|
+
authoritativeBranch: config.authoritativeBranch,
|
|
434
|
+
remote: config.remote,
|
|
435
|
+
currentCommit: summary.commit,
|
|
436
|
+
upstreamCommit: null,
|
|
437
|
+
upstream: summary.upstream,
|
|
438
|
+
ahead: null,
|
|
439
|
+
behind: null,
|
|
440
|
+
pendingCommits: [],
|
|
441
|
+
pendingCommitsFilegrcOnly: null,
|
|
442
|
+
lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
|
|
443
|
+
wholeWorktreeClean: summary.available ? wholeWorktreeClean(root) : null,
|
|
444
|
+
operationInProgress: summary.available ? repositoryOperation(root) : null,
|
|
445
|
+
writesAllowed: false
|
|
446
|
+
};
|
|
447
|
+
if (config.configurationError) {
|
|
448
|
+
return {
|
|
449
|
+
...base,
|
|
450
|
+
status: "git-setup-required",
|
|
451
|
+
label: "Git setup required",
|
|
452
|
+
message: config.configurationError
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
if (!summary.available) {
|
|
456
|
+
return {
|
|
457
|
+
...base,
|
|
458
|
+
status: "git-setup-required",
|
|
459
|
+
label: "Git setup required",
|
|
460
|
+
message: "Git is unavailable. Install Git and open this workspace from its authoritative repository checkout."
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
if (summary.branch !== config.authoritativeBranch) {
|
|
464
|
+
return {
|
|
465
|
+
...base,
|
|
466
|
+
status: "read-only-checkout",
|
|
467
|
+
label: "Read-only checkout",
|
|
468
|
+
message: "This checkout is not the authoritative FileGRC branch. You can review the program here, but browser changes are disabled. Run FileGRC from the main checkout or use the explicit development override."
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
if (!summary.remotes.includes(config.remote)) {
|
|
472
|
+
return {
|
|
473
|
+
...base,
|
|
474
|
+
status: "git-setup-required",
|
|
475
|
+
label: "Git setup required",
|
|
476
|
+
message: `The configured Git remote "${config.remote}" does not exist. Add it and configure the authoritative branch upstream before using browser writes.`
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const expectedUpstream = `${config.remote}/${config.authoritativeBranch}`;
|
|
480
|
+
if (summary.upstream !== expectedUpstream) {
|
|
481
|
+
return {
|
|
482
|
+
...base,
|
|
483
|
+
status: "git-setup-required",
|
|
484
|
+
label: "Git setup required",
|
|
485
|
+
message: `The authoritative branch must track ${expectedUpstream}. Configure that upstream with Git before using browser writes.`
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const upstreamCommit = tryGit(root, ["rev-parse", expectedUpstream]) || null;
|
|
489
|
+
const counts = upstreamCommit ? aheadBehind(root, expectedUpstream) : { ahead: null, behind: null };
|
|
490
|
+
const pendingCommits = counts.ahead > 0 ? commitsAhead(root, expectedUpstream) : [];
|
|
491
|
+
const pendingCommitsFilegrcOnly = counts.ahead > 0 ? commitsOnlyTouchWorkspace(root, expectedUpstream) : true;
|
|
492
|
+
const details = {
|
|
493
|
+
...base,
|
|
494
|
+
upstreamCommit,
|
|
495
|
+
ahead: counts.ahead,
|
|
496
|
+
behind: counts.behind,
|
|
497
|
+
pendingCommits,
|
|
498
|
+
pendingCommitsFilegrcOnly
|
|
499
|
+
};
|
|
500
|
+
if (base.operationInProgress) {
|
|
501
|
+
return {
|
|
502
|
+
...details,
|
|
503
|
+
status: "not-synced",
|
|
504
|
+
label: "Not synced",
|
|
505
|
+
message: `A Git ${base.operationInProgress} is in progress. Finish or abort it with Git before using browser writes.`
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
if (!base.wholeWorktreeClean) {
|
|
509
|
+
return {
|
|
510
|
+
...details,
|
|
511
|
+
status: "not-synced",
|
|
512
|
+
label: "Not synced",
|
|
513
|
+
message: "The Git worktree has uncommitted changes. Commit, discard, or move them with Git before using browser writes."
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
if (counts.ahead === null || counts.behind === null) {
|
|
517
|
+
return {
|
|
518
|
+
...details,
|
|
519
|
+
status: "git-setup-required",
|
|
520
|
+
label: "Git setup required",
|
|
521
|
+
message: `The upstream ${expectedUpstream} is unavailable locally. Fetch ${config.remote} with Git, then reload.`
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
if (counts.ahead > 0 || counts.behind > 0) {
|
|
525
|
+
const external = counts.ahead > 0 && !pendingCommitsFilegrcOnly;
|
|
526
|
+
return {
|
|
527
|
+
...details,
|
|
528
|
+
status: "not-synced",
|
|
529
|
+
label: "Not synced",
|
|
530
|
+
message: external
|
|
531
|
+
? "A commit ahead of upstream changes files outside this FileGRC workspace. Reconcile it with Git. FileGRC will not push it."
|
|
532
|
+
: counts.ahead > 0 && counts.behind > 0
|
|
533
|
+
? "The authoritative branch has diverged from upstream. Reconcile it with Git. FileGRC will not merge or rebase it."
|
|
534
|
+
: counts.ahead > 0
|
|
535
|
+
? "FileGRC-only commits are waiting to be pushed. Use Retry sync."
|
|
536
|
+
: "The authoritative branch is behind upstream. The next browser mutation will fast-forward before writing.",
|
|
537
|
+
writesAllowed: counts.ahead === 0 && counts.behind > 0,
|
|
538
|
+
retrySafe: counts.ahead > 0 && counts.behind === 0 && pendingCommitsFilegrcOnly
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
...details,
|
|
543
|
+
status: "synced",
|
|
544
|
+
label: "Synced",
|
|
545
|
+
message: `The authoritative branch is synchronized with ${expectedUpstream}.`,
|
|
546
|
+
writesAllowed: true,
|
|
547
|
+
retrySafe: false
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function requireTrunkPreconditions(root, config, options = {}) {
|
|
552
|
+
const summary = getGitSummary(root);
|
|
553
|
+
const state = inspectTrunkRepository(root, config, summary);
|
|
554
|
+
if (config.configurationError) throw new Error(state.message);
|
|
555
|
+
if (!summary.available) throw new Error(state.message);
|
|
556
|
+
if (summary.branch !== config.authoritativeBranch) throw new Error(state.message);
|
|
557
|
+
if (!summary.remotes.includes(config.remote)) throw new Error(state.message);
|
|
558
|
+
if (summary.upstream !== `${config.remote}/${config.authoritativeBranch}`) throw new Error(state.message);
|
|
559
|
+
if (state.operationInProgress) throw new Error(state.message);
|
|
560
|
+
if (!state.wholeWorktreeClean) throw new Error(state.message);
|
|
561
|
+
if (!options.allowAhead && state.ahead > 0) {
|
|
562
|
+
throw new Error("The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.");
|
|
563
|
+
}
|
|
564
|
+
return state;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function fetchConfiguredRemote(root, remote) {
|
|
568
|
+
gitForWrite(root, ["fetch", "--prune", "--", remote], `fetch ${remote}`);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function fastForwardConfiguredBranch(root, upstream) {
|
|
572
|
+
gitForWrite(root, ["merge", "--ff-only", "--", upstream], `fast-forward from ${upstream}`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function pushConfiguredBranch(root, config) {
|
|
576
|
+
gitForWrite(
|
|
577
|
+
root,
|
|
578
|
+
["push", "--porcelain", "--", config.remote, `HEAD:refs/heads/${config.authoritativeBranch}`],
|
|
579
|
+
`push ${config.authoritativeBranch} to ${config.remote}`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function wholeWorktreeClean(root) {
|
|
584
|
+
return git(root, ["status", "--porcelain=v1"]) === "";
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function repositoryOperation(root) {
|
|
588
|
+
for (const [name, gitPath] of [
|
|
589
|
+
["merge", "MERGE_HEAD"],
|
|
590
|
+
["rebase", "rebase-merge"],
|
|
591
|
+
["rebase", "rebase-apply"],
|
|
592
|
+
["cherry-pick", "CHERRY_PICK_HEAD"]
|
|
593
|
+
]) {
|
|
594
|
+
const path = tryGit(root, ["rev-parse", "--git-path", gitPath]);
|
|
595
|
+
if (path && existsSync(resolve(root, path))) return name;
|
|
596
|
+
}
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function aheadBehind(root, upstream) {
|
|
601
|
+
const output = tryGit(root, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
|
|
602
|
+
const [ahead, behind] = output.split(/\s+/).map(Number);
|
|
603
|
+
return Number.isInteger(ahead) && Number.isInteger(behind)
|
|
604
|
+
? { ahead, behind }
|
|
605
|
+
: { ahead: null, behind: null };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function commitsAhead(root, upstream) {
|
|
609
|
+
return lines(tryGit(root, ["log", "--format=%H%x1f%s", `${upstream}..HEAD`])).map((line) => {
|
|
610
|
+
const [commit, subject] = line.split("\x1f");
|
|
611
|
+
return { commit, shortCommit: commit.slice(0, 8), subject };
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function commitsOnlyTouchWorkspace(root, upstream) {
|
|
616
|
+
const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
|
|
617
|
+
const prefix = relative(topLevel, root).split(sep).join("/");
|
|
618
|
+
const commits = lines(tryGit(root, ["rev-list", `${upstream}..HEAD`]));
|
|
619
|
+
return commits.every((commit) => {
|
|
620
|
+
const paths = nulFields(tryGitRaw(topLevel, [
|
|
621
|
+
"diff-tree",
|
|
622
|
+
"--no-commit-id",
|
|
623
|
+
"--name-only",
|
|
624
|
+
"-z",
|
|
625
|
+
"-r",
|
|
626
|
+
"--root",
|
|
627
|
+
commit
|
|
628
|
+
]));
|
|
629
|
+
return paths.length > 0 && paths.every((path) => pathInsideWorkspace(path, prefix));
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function assertNoOutsideWorktreeChanges(root, rollbackExpected = true) {
|
|
634
|
+
const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
|
|
635
|
+
const prefix = relative(topLevel, root).split(sep).join("/");
|
|
636
|
+
const paths = statusPaths(topLevel);
|
|
637
|
+
if (paths.some((path) => !pathInsideWorkspace(path, prefix))) {
|
|
638
|
+
throw new Error(rollbackExpected
|
|
639
|
+
? "Files outside this FileGRC workspace changed while the browser action was running. The FileGRC change was rolled back; reconcile the other Git work first."
|
|
640
|
+
: "Files outside this FileGRC workspace changed while the browser action was being staged. The saved FileGRC files remain uncommitted and later browser mutations are blocked; reconcile the Git worktree.");
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function assertOnlyWorkspaceFilesStaged(root) {
|
|
645
|
+
const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
|
|
646
|
+
const prefix = relative(topLevel, root).split(sep).join("/");
|
|
647
|
+
const staged = nulFields(tryGitRaw(topLevel, [
|
|
648
|
+
"diff",
|
|
649
|
+
"--cached",
|
|
650
|
+
"--name-only",
|
|
651
|
+
"-z",
|
|
652
|
+
"--diff-filter=ACDMRTUXB"
|
|
653
|
+
]));
|
|
654
|
+
if (!staged.length) throw new Error("The browser action did not stage any FileGRC workspace files.");
|
|
655
|
+
if (staged.some((path) => !pathInsideWorkspace(path, prefix))) {
|
|
656
|
+
throw new Error("Git has staged files outside this FileGRC workspace. FileGRC will not create a browser commit until those files are unstaged.");
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function rollbackWorkspaceChanges(root) {
|
|
661
|
+
gitForWrite(root, ["restore", "--staged", "--worktree", "--source=HEAD", "--", "."], "roll back the FileGRC workspace change");
|
|
662
|
+
const untracked = nulFields(tryGitRaw(root, ["ls-files", "-z", "--others", "--exclude-standard", "--", "."]));
|
|
663
|
+
for (const path of untracked) {
|
|
664
|
+
const absolute = resolve(root, path);
|
|
665
|
+
if (absolute === root || !absolute.startsWith(`${root}${sep}`)) continue;
|
|
666
|
+
await rm(absolute, { force: true });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function statusPaths(topLevel) {
|
|
671
|
+
const output = tryGitRaw(topLevel, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
672
|
+
if (!output) return [];
|
|
673
|
+
const fields = nulFields(output);
|
|
674
|
+
const paths = [];
|
|
675
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
676
|
+
const field = fields[index];
|
|
677
|
+
if (!/^[ MADRCU?!]{2} /.test(field)) {
|
|
678
|
+
paths.push(field);
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
const status = field.slice(0, 2);
|
|
682
|
+
paths.push(field.slice(3));
|
|
683
|
+
if (/[RC]/.test(status) && fields[index + 1] !== undefined) paths.push(fields[++index]);
|
|
684
|
+
}
|
|
685
|
+
return paths;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function pathInsideWorkspace(path, prefix) {
|
|
689
|
+
return !prefix || path === prefix || path.startsWith(`${prefix}/`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function generatedCommitMessage(value) {
|
|
693
|
+
const subject = String(value ?? "")
|
|
694
|
+
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
695
|
+
.replace(/\s+/g, " ")
|
|
696
|
+
.trim();
|
|
697
|
+
return (subject || "Update FileGRC workspace").slice(0, 200);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function cleanGitName(value, fallback) {
|
|
701
|
+
const normalized = String(value ?? fallback).trim();
|
|
702
|
+
return normalized || fallback;
|
|
703
|
+
}
|
|
704
|
+
|
|
236
705
|
function parseLogLine(line) {
|
|
237
706
|
if (!line) return null;
|
|
238
707
|
const [commit, timestamp, author, subject] = line.split("\x1f");
|
|
@@ -261,6 +730,28 @@ function tryGit(cwd, args) {
|
|
|
261
730
|
}
|
|
262
731
|
}
|
|
263
732
|
|
|
733
|
+
function tryGitRaw(cwd, args) {
|
|
734
|
+
try {
|
|
735
|
+
return gitRaw(cwd, args);
|
|
736
|
+
} catch {
|
|
737
|
+
return "";
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function gitRaw(cwd, args) {
|
|
742
|
+
return execFileSync("git", args, {
|
|
743
|
+
cwd,
|
|
744
|
+
encoding: "utf8",
|
|
745
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
746
|
+
timeout: 10_000,
|
|
747
|
+
maxBuffer: 20_000_000
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function nulFields(source) {
|
|
752
|
+
return source ? source.split("\0").filter(Boolean) : [];
|
|
753
|
+
}
|
|
754
|
+
|
|
264
755
|
function gitForWrite(cwd, args, action = "create the commit") {
|
|
265
756
|
try {
|
|
266
757
|
return execFileSync("git", args, {
|
|
@@ -276,11 +767,18 @@ function gitForWrite(cwd, args, action = "create the commit") {
|
|
|
276
767
|
}
|
|
277
768
|
}).trim();
|
|
278
769
|
} catch (error) {
|
|
279
|
-
const message = error.stderr?.trim() || error.stdout?.trim() || error.message;
|
|
770
|
+
const message = sanitizeGitErrorMessage(error.stderr?.trim() || error.stdout?.trim() || error.message);
|
|
280
771
|
throw new Error(`Git could not ${action}. ${message}`);
|
|
281
772
|
}
|
|
282
773
|
}
|
|
283
774
|
|
|
775
|
+
export function sanitizeGitErrorMessage(value) {
|
|
776
|
+
return String(value || "Git returned no error detail.")
|
|
777
|
+
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, "$1[redacted]@")
|
|
778
|
+
.replace(/([?&](?:access[_-]?token|auth|key|password|secret|token)=)[^&\s]+/gi, "$1[redacted]")
|
|
779
|
+
.replace(/\b(authorization:\s*)(?:basic|bearer)\s+\S+/gi, "$1[redacted]");
|
|
780
|
+
}
|
|
781
|
+
|
|
284
782
|
function tryGitForWrite(cwd, args) {
|
|
285
783
|
try {
|
|
286
784
|
gitForWrite(cwd, args);
|
package/src/index.js
CHANGED
|
@@ -18,11 +18,14 @@ export {
|
|
|
18
18
|
export {
|
|
19
19
|
commitAndPushWorkspace,
|
|
20
20
|
commitWorkspace,
|
|
21
|
+
getBrowserRepositoryState,
|
|
21
22
|
getFileHistory,
|
|
22
23
|
getGitSummary,
|
|
23
24
|
getWorkspaceHistories,
|
|
24
25
|
pullWorkspace,
|
|
25
|
-
pushWorkspace
|
|
26
|
+
pushWorkspace,
|
|
27
|
+
retryBrowserSync,
|
|
28
|
+
runBrowserMutation
|
|
26
29
|
} from "./git.js";
|
|
27
30
|
export { generateModelDocumentation } from "./model-docs.js";
|
|
28
31
|
export { renderMarkdown } from "./markdown.js";
|
package/src/mutation.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { resolveWorkspaceRoot } from "./paths.js";
|
|
2
3
|
|
|
3
4
|
const mutationQueues = new Map();
|
|
5
|
+
const activeMutation = new AsyncLocalStorage();
|
|
4
6
|
|
|
5
7
|
export function serializeWorkspaceMutation(input, task) {
|
|
6
8
|
const root = resolveWorkspaceRoot(input);
|
|
9
|
+
if (activeMutation.getStore() === root) return task(root);
|
|
7
10
|
const previous = mutationQueues.get(root) ?? Promise.resolve();
|
|
8
|
-
const run = previous.catch(() => {}).then(() => task(root));
|
|
11
|
+
const run = previous.catch(() => {}).then(() => activeMutation.run(root, () => task(root)));
|
|
9
12
|
let tracked;
|
|
10
13
|
tracked = run.finally(() => {
|
|
11
14
|
if (mutationQueues.get(root) === tracked) mutationQueues.delete(root);
|
package/src/server.js
CHANGED
|
@@ -7,7 +7,15 @@ import { generateEvidencePacket, prepareEvidencePacket } from "./evidence-packet
|
|
|
7
7
|
import { ensureEvidenceTestDrafts } from "./evidence-tests.js";
|
|
8
8
|
import { FAVICON_PNG, LOGO_MARK_PNG } from "./favicon.js";
|
|
9
9
|
import { createResource, deleteResource, updateContent, updateResource } from "./files.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
commitAndPushWorkspace,
|
|
12
|
+
getBrowserRepositoryState,
|
|
13
|
+
getFileHistory,
|
|
14
|
+
pullWorkspace,
|
|
15
|
+
pushWorkspace,
|
|
16
|
+
retryBrowserSync,
|
|
17
|
+
runBrowserMutation
|
|
18
|
+
} from "./git.js";
|
|
11
19
|
import { completeObligationOccurrence, createObligationEvent, planObligations } from "./obligations.js";
|
|
12
20
|
import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
|
|
13
21
|
import { createAppState } from "./state.js";
|
|
@@ -26,7 +34,9 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
26
34
|
return json(response, 403, { error: "Cross-origin writes are not allowed." });
|
|
27
35
|
}
|
|
28
36
|
if (request.method === "GET" && url.pathname === "/api/state") {
|
|
29
|
-
return json(response, 200, await createAppState(input
|
|
37
|
+
return json(response, 200, await createAppState(input, {
|
|
38
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
39
|
+
}));
|
|
30
40
|
}
|
|
31
41
|
if (request.method === "GET" && url.pathname === "/api/history") {
|
|
32
42
|
const path = url.searchParams.get("path");
|
|
@@ -44,17 +54,22 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
44
54
|
}));
|
|
45
55
|
}
|
|
46
56
|
if (request.method === "POST" && url.pathname === "/api/obligation-events") {
|
|
47
|
-
|
|
57
|
+
const payload = await readJson(request);
|
|
58
|
+
return json(response, 201, await browserMutation(input, options, {
|
|
59
|
+
message: (result) => `Create policy event: ${result.event?.title || payload.title || payload.eventType}`
|
|
60
|
+
}, () => createObligationEvent(input, payload)));
|
|
48
61
|
}
|
|
49
62
|
if (request.method === "POST" && url.pathname === "/api/obligation-completions") {
|
|
50
63
|
const payload = await readJson(request);
|
|
51
64
|
if (!safeSegment(payload.obligationId)) return json(response, 400, { error: "A safe obligation ID is required." });
|
|
52
|
-
const result = await
|
|
65
|
+
const result = await browserMutation(input, options, {
|
|
66
|
+
message: () => `Complete ${resourceTypeLabel(payload.record?.type)}: ${payload.record?.title || payload.obligationId}`
|
|
67
|
+
}, () => completeObligationOccurrence(input, {
|
|
53
68
|
obligationId: payload.obligationId,
|
|
54
69
|
record: payload.record,
|
|
55
70
|
content: payload.content,
|
|
56
71
|
expectedRevision: payload.revision
|
|
57
|
-
});
|
|
72
|
+
}));
|
|
58
73
|
return json(response, 201, result);
|
|
59
74
|
}
|
|
60
75
|
if (request.method === "GET" && url.pathname === "/api/evidence-packet") {
|
|
@@ -82,34 +97,54 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
82
97
|
});
|
|
83
98
|
}
|
|
84
99
|
if (request.method === "POST" && url.pathname === "/api/audit-preparation") {
|
|
85
|
-
|
|
100
|
+
const payload = await readJson(request);
|
|
101
|
+
return json(response, 201, await browserMutation(input, options, {
|
|
102
|
+
message: () => `Prepare audit: ${payload.auditId || "engagement"}`
|
|
103
|
+
}, () => prepareAuditWorkspace(input, payload)));
|
|
86
104
|
}
|
|
87
105
|
if (request.method === "POST" && url.pathname === "/api/evidence-test-drafts") {
|
|
88
|
-
return json(response, 201, await
|
|
106
|
+
return json(response, 201, await browserMutation(input, options, {
|
|
107
|
+
message: "Create evidence collection test drafts"
|
|
108
|
+
}, () => ensureEvidenceTestDrafts(input)));
|
|
89
109
|
}
|
|
90
110
|
if (request.method === "POST" && url.pathname === "/api/setup") {
|
|
91
|
-
|
|
111
|
+
const payload = await readJson(request);
|
|
112
|
+
return json(response, 200, await browserMutation(input, options, {
|
|
113
|
+
message: (result) => `${payload.draft === true ? "Save onboarding draft" : "Complete onboarding"} for ${result.workspace.organizationName}`
|
|
114
|
+
}, () => setupWorkspace(input, payload)));
|
|
92
115
|
}
|
|
93
116
|
if (request.method === "POST" && url.pathname === "/api/resources") {
|
|
94
117
|
const payload = await readJson(request);
|
|
95
118
|
const record = payload.record ?? payload;
|
|
96
|
-
const result = await
|
|
97
|
-
|
|
119
|
+
const result = await browserMutation(input, options, {
|
|
120
|
+
message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}`
|
|
121
|
+
}, () => createResource(input, record, { content: payload.record ? payload.content : undefined }));
|
|
122
|
+
return json(response, 201, { record: result.record, synchronization: result.synchronization });
|
|
98
123
|
}
|
|
99
124
|
if (request.method === "POST" && url.pathname === "/api/commit") {
|
|
125
|
+
await requireManualBrowserGit(input, options);
|
|
100
126
|
const payload = await readJson(request);
|
|
101
127
|
return json(response, 201, await commitAndPushWorkspace(input, payload.message));
|
|
102
128
|
}
|
|
103
129
|
if (request.method === "POST" && url.pathname === "/api/git/pull") {
|
|
130
|
+
await requireManualBrowserGit(input, options);
|
|
104
131
|
return json(response, 200, await pullWorkspace(input));
|
|
105
132
|
}
|
|
106
133
|
if (request.method === "POST" && url.pathname === "/api/git/push") {
|
|
134
|
+
await requireManualBrowserGit(input, options);
|
|
107
135
|
return json(response, 200, await pushWorkspace(input));
|
|
108
136
|
}
|
|
137
|
+
if (request.method === "POST" && url.pathname === "/api/git/retry-sync") {
|
|
138
|
+
return json(response, 200, await retryBrowserSync(input, {
|
|
139
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
109
142
|
if (request.method === "PUT" && url.pathname === "/api/content") {
|
|
110
143
|
const payload = await readJson(request);
|
|
111
|
-
const result = await
|
|
112
|
-
|
|
144
|
+
const result = await browserMutation(input, options, {
|
|
145
|
+
message: () => `Update content: ${payload.path}`
|
|
146
|
+
}, () => updateContent(input, payload.path, payload.source, { expectedRevision: payload.revision }));
|
|
147
|
+
return json(response, 200, { path: result.dataRelativePath, synchronization: result.synchronization });
|
|
113
148
|
}
|
|
114
149
|
const match = /^\/api\/resource\/([^/]+)\/([^/]+)$/.exec(url.pathname);
|
|
115
150
|
if (match) {
|
|
@@ -124,16 +159,26 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
124
159
|
if (request.method === "PUT") {
|
|
125
160
|
const payload = await readJson(request);
|
|
126
161
|
const record = payload.record ?? payload;
|
|
127
|
-
const result = await
|
|
162
|
+
const result = await browserMutation(input, options, {
|
|
163
|
+
message: () => `Update ${resourceTypeLabel(type)}: ${record.title || id}`
|
|
164
|
+
}, () => updateResource(input, type, id, record, {
|
|
128
165
|
content: payload.record ? payload.content : undefined,
|
|
129
166
|
expectedRevision: payload.revision,
|
|
130
167
|
expectedContentRevisions: payload.contentRevisions
|
|
131
|
-
});
|
|
132
|
-
return json(response, 200, { record: result.record });
|
|
168
|
+
}));
|
|
169
|
+
return json(response, 200, { record: result.record, synchronization: result.synchronization });
|
|
133
170
|
}
|
|
134
171
|
if (request.method === "DELETE") {
|
|
135
|
-
const result = await
|
|
136
|
-
|
|
172
|
+
const result = await browserMutation(input, options, {
|
|
173
|
+
message: () => `Delete ${resourceTypeLabel(type)}: ${id}`
|
|
174
|
+
}, () => deleteResource(input, type, id, { expectedRevision: url.searchParams.get("revision") }));
|
|
175
|
+
return json(response, 200, {
|
|
176
|
+
deleted: true,
|
|
177
|
+
type,
|
|
178
|
+
id,
|
|
179
|
+
deletedContent: result.deletedContent,
|
|
180
|
+
synchronization: result.synchronization
|
|
181
|
+
});
|
|
137
182
|
}
|
|
138
183
|
}
|
|
139
184
|
if (request.method === "GET" && url.pathname === "/favicon.png") return text(response, 200, FAVICON_PNG, "image/png");
|
|
@@ -185,7 +230,10 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
185
230
|
}
|
|
186
231
|
const loaded = await loadWorkspace(input);
|
|
187
232
|
getResourceDefinition(loaded.model, "workspace");
|
|
188
|
-
const server = createFilegrcServer(loaded.root, {
|
|
233
|
+
const server = createFilegrcServer(loaded.root, {
|
|
234
|
+
allowedHosts: [host],
|
|
235
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
|
|
236
|
+
});
|
|
189
237
|
await new Promise((resolve, reject) => {
|
|
190
238
|
server.once("error", reject);
|
|
191
239
|
server.listen(port, host, resolve);
|
|
@@ -198,6 +246,26 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
198
246
|
};
|
|
199
247
|
}
|
|
200
248
|
|
|
249
|
+
function browserMutation(input, options, mutationOptions, task) {
|
|
250
|
+
return runBrowserMutation(input, {
|
|
251
|
+
...mutationOptions,
|
|
252
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
|
|
253
|
+
}, task);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function requireManualBrowserGit(input, options) {
|
|
257
|
+
const repository = await getBrowserRepositoryState(input, {
|
|
258
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
259
|
+
});
|
|
260
|
+
if (repository.mode === "trunk") {
|
|
261
|
+
throw new Error("Browser commit, pull, and push controls are disabled in trunk mode. Saved changes synchronize automatically.");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function resourceTypeLabel(value) {
|
|
266
|
+
return String(value || "record").replaceAll("-", " ");
|
|
267
|
+
}
|
|
268
|
+
|
|
201
269
|
async function readJson(request) {
|
|
202
270
|
const chunks = [];
|
|
203
271
|
let size = 0;
|
|
@@ -296,7 +364,7 @@ function statusFor(error) {
|
|
|
296
364
|
if (/exceeds 2 MB/i.test(error.message)) return 413;
|
|
297
365
|
if (/changed after you opened|source changed|revision changed/i.test(error.message)) return 409;
|
|
298
366
|
if (/already exists|target file already exists/i.test(error.message)) return 409;
|
|
299
|
-
if (/Git could not
|
|
367
|
+
if (/Git could not|upstream branch|multiple remotes|no Git remote|configured repository remote|safe Git name|check out a branch|before trying to (?:pull|push)|authoritative branch|not synchronized|not synced|diverged|waiting to be pushed|Retry sync|outside this FileGRC workspace|worktree has uncommitted changes|development write override|browser commit, pull, and push/i.test(error.message)) return 409;
|
|
300
368
|
if (/not found|ENOENT/i.test(error.message)) return 404;
|
|
301
369
|
if (/invalid|required|unsafe|match|workspace|singleton|commit message|no changes|git history|git user|unknown resource type|must use|must be|content path|data path|path leaves|valid .*date|not found|no active obligations|end date|through date|already exists|EEXIST/i.test(error.message)) return 400;
|
|
302
370
|
return 500;
|
package/src/state.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { assessAuditPreparation } from "./audit-preparation.js";
|
|
4
|
-
import { getGitSummary, getWorkspaceHistories } from "./git.js";
|
|
4
|
+
import { getBrowserRepositoryState, getGitSummary, getWorkspaceHistories } from "./git.js";
|
|
5
5
|
import { renderMarkdown } from "./markdown.js";
|
|
6
6
|
import { planObligations } from "./obligations.js";
|
|
7
7
|
import { resolveDataPath } from "./paths.js";
|
|
@@ -42,6 +42,10 @@ export async function createAppState(input = process.cwd(), options = {}) {
|
|
|
42
42
|
|
|
43
43
|
const git = getGitSummary(loaded.root);
|
|
44
44
|
delete git.root;
|
|
45
|
+
const repository = await getBrowserRepositoryState(loaded.root, {
|
|
46
|
+
readOnly: options.readOnly,
|
|
47
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
48
|
+
});
|
|
45
49
|
const workspace = loaded.workspace ?? {
|
|
46
50
|
schemaVersion: 1,
|
|
47
51
|
dataModelVersion: loaded.model.modelVersion,
|
|
@@ -70,7 +74,8 @@ export async function createAppState(input = process.cwd(), options = {}) {
|
|
|
70
74
|
));
|
|
71
75
|
return {
|
|
72
76
|
generatedAt,
|
|
73
|
-
readOnly: Boolean(options.readOnly),
|
|
77
|
+
readOnly: Boolean(options.readOnly || (repository.mode === "trunk" && !repository.writesAllowed)),
|
|
78
|
+
repository,
|
|
74
79
|
workspace,
|
|
75
80
|
model: loaded.model,
|
|
76
81
|
resources: entries,
|
package/src/validate.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { stat } from "node:fs/promises";
|
|
2
2
|
import { getResourceDefinition } from "../model/index.js";
|
|
3
|
+
import { isSafeGitName } from "./git-name.js";
|
|
3
4
|
import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
|
|
4
5
|
import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
|
|
5
6
|
import { obligationIsRunning } from "./program-lifecycle.js";
|
|
@@ -476,6 +477,7 @@ function validateValue(name, value, field, model, path, diagnostics) {
|
|
|
476
477
|
}
|
|
477
478
|
if (field.format === "email" && !EMAIL_PATTERN.test(value)) fail("must be an email address.");
|
|
478
479
|
if (field.format === "timezone" && !isTimezone(value)) fail("must be an IANA time zone.");
|
|
480
|
+
if (field.format === "git-name" && !isSafeGitName(value)) fail("must be a safe Git name.");
|
|
479
481
|
}
|
|
480
482
|
|
|
481
483
|
function validateNumericRange(value, field, fail) {
|
package/src/web.js
CHANGED
|
@@ -217,7 +217,19 @@ function topbar(route) {
|
|
|
217
217
|
: route.name === "audit-packet"
|
|
218
218
|
? "Audit Readiness"
|
|
219
219
|
: state.model.resources[route.type]?.pluralTitle || "filegrc";
|
|
220
|
-
|
|
220
|
+
const repositoryLabel = state.repository?.mode === "trunk"
|
|
221
|
+
? state.repository.label
|
|
222
|
+
: state.git.available ? ((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable";
|
|
223
|
+
const repositoryTone = state.repository?.mode === "trunk"
|
|
224
|
+
? repositoryStatusTone(state.repository.status)
|
|
225
|
+
: state.git.clean ? "good" : "warn";
|
|
226
|
+
return '<button class="mobile-nav" type="button" aria-label="Open navigation" aria-controls="sidebar-navigation" aria-expanded="false">☰</button><div><small class="eyebrow">' + esc(state.workspace.organizationName) + '</small><h1>' + esc(titleCase(title)) + '</h1></div><label class="search"><span aria-hidden="true">⌕</span><input id="global-search" type="search" placeholder="Search records" aria-label="Search records"><kbd>/</kbd></label><div class="topbar-status"><a class="validation-chip" href="#/repository"><span class="status-dot ' + (state.validation.ok ? "good" : "bad") + '"></span>' + (state.validation.ok ? "Data valid" : state.validation.counts.errors + " validation errors") + '</a><a class="repo-chip" href="#/repository"><span class="status-dot ' + repositoryTone + '"></span>' + esc(repositoryLabel) + '</a></div>';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function repositoryStatusTone(status) {
|
|
230
|
+
if (status === "synced") return "good";
|
|
231
|
+
if (status === "syncing") return "neutral";
|
|
232
|
+
return "warn";
|
|
221
233
|
}
|
|
222
234
|
|
|
223
235
|
function renderHome(main) {
|
|
@@ -1132,6 +1144,7 @@ function renderOrganization(main) {
|
|
|
1132
1144
|
}
|
|
1133
1145
|
|
|
1134
1146
|
function renderRepository(main) {
|
|
1147
|
+
if (state.repository?.mode === "trunk") return renderTrunkRepository(main);
|
|
1135
1148
|
const settings = rendererSettingsEntry();
|
|
1136
1149
|
const settingsLink = settings ? '<a class="button" href="#/resource/renderer-settings/' + encodeURIComponent(settings.record.id) + '">Renderer settings</a>' : "";
|
|
1137
1150
|
const onboardingButton = settings && !state.readOnly ? '<button class="button" type="button" id="start-onboarding">Run onboarding</button>' : "";
|
|
@@ -1177,12 +1190,41 @@ function renderRepository(main) {
|
|
|
1177
1190
|
main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
|
|
1178
1191
|
}
|
|
1179
1192
|
|
|
1193
|
+
function renderTrunkRepository(main) {
|
|
1194
|
+
const repository = state.repository;
|
|
1195
|
+
const settings = rendererSettingsEntry();
|
|
1196
|
+
const settingsLink = settings ? '<a class="button" href="#/resource/renderer-settings/' + encodeURIComponent(settings.record.id) + '">Renderer settings</a>' : "";
|
|
1197
|
+
const onboardingButton = settings && repository.writesAllowed
|
|
1198
|
+
? '<button class="button" type="button" id="start-onboarding">Run onboarding</button>'
|
|
1199
|
+
: "";
|
|
1200
|
+
const retryButton = repository.retrySafe
|
|
1201
|
+
? '<button class="button primary" type="button" data-git-action="retry-sync">Retry sync</button>'
|
|
1202
|
+
: "";
|
|
1203
|
+
const pending = repository.pendingCommitsFilegrcOnly === false
|
|
1204
|
+
? empty("Ahead commits include files outside this FileGRC workspace. Reconcile them with Git.")
|
|
1205
|
+
: repository.pendingCommits?.length
|
|
1206
|
+
? '<ul class="changes">' + repository.pendingCommits.map((commit) => '<li><code>' + esc(commit.shortCommit) + '</code> ' + esc(commit.subject) + '</li>').join("") + '</ul>'
|
|
1207
|
+
: empty("No FileGRC commits are waiting to be pushed.");
|
|
1208
|
+
const lastSync = repository.lastSuccessfulSynchronization
|
|
1209
|
+
? formatLocalDateTime(repository.lastSuccessfulSynchronization)
|
|
1210
|
+
: "No successful sync recorded by this server";
|
|
1211
|
+
const override = repository.developmentOverride
|
|
1212
|
+
? '<div class="repository-override"><span class="status-dot warn"></span><div><strong>Development write override active</strong><p>Browser writes stay local. FileGRC will not fetch, commit, or push while this server uses <code>--allow-non-authoritative-writes</code>.</p></div></div>'
|
|
1213
|
+
: "";
|
|
1214
|
+
const validationBody = state.validation.diagnostics.length
|
|
1215
|
+
? '<div class="diagnostics">' + state.validation.diagnostics.map((item) => '<div><span class="badge ' + item.severity + '">' + esc(properCase(item.severity)) + '</span><code>' + esc(item.path) + '</code><p>' + esc(item.message) + '</p></div>').join("") + '</div>'
|
|
1216
|
+
: empty("No validation problems.");
|
|
1217
|
+
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>Browser saves use one authoritative branch. Record status represents draft, proposal, approval, and retirement; Git branches do not.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + retryButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div>' + override + '<section class="panel repository-state-banner"><span class="status-dot ' + repositoryStatusTone(repository.status) + '"></span><div><p class="kicker">Repository status</p><h3>' + esc(repository.label) + '</h3><p>' + esc(repository.message) + '</p></div></section><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Configured Repository</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(repository.authoritativeBranch) + '</dd></div><div><dt>Remote</dt><dd>' + esc(repository.remote) + '</dd></div><div><dt>Checkout</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(repository.upstream || "Not configured") + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Synchronization</h3></div><dl class="metadata"><div><dt>Current commit</dt><dd><code>' + esc(repository.currentCommit || "Unavailable") + '</code></dd></div><div><dt>Upstream commit</dt><dd><code>' + esc(repository.upstreamCommit || "Unavailable") + '</code></dd></div><div><dt>Ahead</dt><dd>' + esc(repository.ahead ?? "Unknown") + '</dd></div><div><dt>Behind</dt><dd>' + esc(repository.behind ?? "Unknown") + '</dd></div><div><dt>Last sync</dt><dd>' + esc(lastSync) + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Safety Checks</h3></div><dl class="metadata"><div><dt>Whole worktree</dt><dd>' + (repository.wholeWorktreeClean === null ? "Unavailable" : repository.wholeWorktreeClean ? "Clean" : "Has changes") + '</dd></div><div><dt>Git operation</dt><dd>' + esc(repository.operationInProgress || "None") + '</dd></div><div><dt>Pending scope</dt><dd>' + (repository.pendingCommitsFilegrcOnly === false ? "Includes external files" : repository.pendingCommits?.length ? "FileGRC only" : "None") + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Pending FileGRC-only Commits</h3></div>' + pending + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section></div></div>';
|
|
1218
|
+
main.querySelectorAll("[data-git-action]").forEach((button) => button.addEventListener("click", () => runRepositoryGitAction(button.dataset.gitAction)));
|
|
1219
|
+
main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1180
1222
|
async function runRepositoryGitAction(action) {
|
|
1181
1223
|
const buttons = [...document.querySelectorAll("[data-git-action]")];
|
|
1182
1224
|
const disabled = buttons.map((button) => button.disabled);
|
|
1183
1225
|
const active = buttons.find((button) => button.dataset.gitAction === action);
|
|
1184
1226
|
const status = document.querySelector(".repository-sync-status");
|
|
1185
|
-
const label = action === "pull" ? "Pulling…" : "Pushing…";
|
|
1227
|
+
const label = action === "pull" ? "Pulling…" : action === "retry-sync" ? "Syncing…" : "Pushing…";
|
|
1186
1228
|
if (status) {
|
|
1187
1229
|
status.textContent = "";
|
|
1188
1230
|
status.classList.remove("error");
|
|
@@ -1200,10 +1242,12 @@ async function runRepositoryGitAction(action) {
|
|
|
1200
1242
|
? result.updated
|
|
1201
1243
|
? "Pulled " + result.upstream + " with rebase at " + result.shortCommit + "."
|
|
1202
1244
|
: result.branch + " is current with " + result.upstream + "."
|
|
1203
|
-
:
|
|
1245
|
+
: action === "retry-sync"
|
|
1246
|
+
? "Synchronized " + result.shortCommit + " with " + result.upstream + "."
|
|
1247
|
+
: "Pushed " + result.shortCommit + " to " + result.upstream + ".";
|
|
1204
1248
|
} catch (cause) {
|
|
1205
1249
|
buttons.forEach((button, index) => { button.disabled = disabled[index]; });
|
|
1206
|
-
if (active) active.textContent = action === "pull" ? "Pull with rebase" : "Push";
|
|
1250
|
+
if (active) active.textContent = action === "pull" ? "Pull with rebase" : action === "retry-sync" ? "Retry sync" : "Push";
|
|
1207
1251
|
if (status) {
|
|
1208
1252
|
status.textContent = cause.message;
|
|
1209
1253
|
status.classList.add("error");
|
|
@@ -1413,8 +1457,9 @@ function onboardingSteps() {
|
|
|
1413
1457
|
points: [
|
|
1414
1458
|
"Use the UI, an editor, the CLI, or an agent; every path changes the same files.",
|
|
1415
1459
|
"JSON holds structured records. Markdown holds policies, plans, minutes, and other long-form work.",
|
|
1416
|
-
"
|
|
1417
|
-
"
|
|
1460
|
+
"In trunk mode, each browser save fast-forwards, validates, creates one focused commit, and pushes it.",
|
|
1461
|
+
"Record status represents approval. Draft, proposed, approved, and retired records all stay on the authoritative branch.",
|
|
1462
|
+
"Agents and terminal users continue to manage Git explicitly.",
|
|
1418
1463
|
"The dashboard derives program status from the current repository state."
|
|
1419
1464
|
]
|
|
1420
1465
|
};
|
|
@@ -1562,14 +1607,12 @@ function onboardingSetupForm() {
|
|
|
1562
1607
|
currentSystem ? "Updates system " + currentSystem.title + "." : "Creates a new in-scope system.",
|
|
1563
1608
|
"Records a management program goal without creating an audit engagement."
|
|
1564
1609
|
].filter(Boolean).join(" ");
|
|
1565
|
-
const gitStatus = state.
|
|
1566
|
-
? '<div class="onboarding-git-status warning"><span class="status-dot
|
|
1567
|
-
: state.git.available && state.git.
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
: '<div class="onboarding-git-status warning"><span class="status-dot warn"></span><span><strong>Git setup needed</strong><small>Saving still works. Run <code>git init</code> at the workspace root before your first compliance commit.</small></span></div>';
|
|
1572
|
-
return '<p class="onboarding-body">' + esc(onboardingSteps().at(-1).body) + '</p>' + gitStatus + '<form id="onboarding-setup" class="onboarding-form"><label class="wide"><span>Service name</span><input name="serviceName" required maxlength="200" value="' + esc(onboardingDraft.serviceName) + '" placeholder="Customer-facing application"></label><label class="wide"><span>Scope description</span><textarea name="scope" required maxlength="2000" placeholder="What the service does and which production boundary is in scope">' + esc(onboardingDraft.scope) + '</textarea></label><label><span>Accountable owner</span><select name="ownerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === onboardingDraft.ownerId ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Business criticality</span><select name="criticality" required>' + ["low", "medium", "high", "critical"].map((value) => '<option value="' + value + '" ' + (value === onboardingDraft.criticality ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Highest data classification</span><select name="dataClassification" required>' + classifications.map((value) => '<option value="' + esc(value) + '" ' + (value === onboardingDraft.dataClassification ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Internet exposed</span><select name="internetExposed" required><option value="true" ' + (onboardingDraft.internetExposed === "true" ? "selected" : "") + '>Yes</option><option value="false" ' + (onboardingDraft.internetExposed === "false" ? "selected" : "") + '>No</option></select></label><label class="wide"><span>Program goal</span><select name="programGoal" required><option value="none" ' + (onboardingDraft.programGoal === "none" ? "selected" : "") + '>No Assurance Goal Yet</option><option value="readiness" ' + (onboardingDraft.programGoal === "readiness" ? "selected" : "") + '>Program Readiness</option><option value="type-1" ' + (onboardingDraft.programGoal === "type-1" ? "selected" : "") + '>SOC 2 Type 1</option><option value="type-2" ' + (onboardingDraft.programGoal === "type-2" ? "selected" : "") + '>SOC 2 Type 2</option></select><small>This records management intent only. It does not create an engagement or establish the formal report period.</small></label></form><p class="onboarding-write-note">' + esc(existing) + ' Save draft marks the service Planned and In scope. It is selected for scope review, but it is not approved or active. Saving writes JSON files but does not commit them. Complete the remaining Step 1 pages next.</p>';
|
|
1610
|
+
const gitStatus = state.repository?.mode === "trunk"
|
|
1611
|
+
? '<div class="onboarding-git-status ' + (state.repository.status === "synced" ? "" : "warning") + '"><span class="status-dot ' + repositoryStatusTone(state.repository.status) + '"></span><span><strong>' + esc(state.repository.label) + '</strong><small>' + esc(state.repository.status === "synced" ? "Completing onboarding will save its related workspace, system, and renderer changes in one commit and push it." : state.repository.message) + '</small></span></div>'
|
|
1612
|
+
: state.git.available && state.git.branch
|
|
1613
|
+
? '<div class="onboarding-git-status"><span class="status-dot good"></span><span><strong>Manual repository mode</strong><small>Setup changes will stay local until you commit and synchronize them.</small></span></div>'
|
|
1614
|
+
: '<div class="onboarding-git-status warning"><span class="status-dot warn"></span><span><strong>Git setup needed</strong><small>Manual-mode writes still work, but Git history is unavailable until the repository is configured.</small></span></div>';
|
|
1615
|
+
return '<p class="onboarding-body">' + esc(onboardingSteps().at(-1).body) + '</p>' + gitStatus + '<form id="onboarding-setup" class="onboarding-form"><label class="wide"><span>Service name</span><input name="serviceName" required maxlength="200" value="' + esc(onboardingDraft.serviceName) + '" placeholder="Customer-facing application"></label><label class="wide"><span>Scope description</span><textarea name="scope" required maxlength="2000" placeholder="What the service does and which production boundary is in scope">' + esc(onboardingDraft.scope) + '</textarea></label><label><span>Accountable owner</span><select name="ownerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === onboardingDraft.ownerId ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Business criticality</span><select name="criticality" required>' + ["low", "medium", "high", "critical"].map((value) => '<option value="' + value + '" ' + (value === onboardingDraft.criticality ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Highest data classification</span><select name="dataClassification" required>' + classifications.map((value) => '<option value="' + esc(value) + '" ' + (value === onboardingDraft.dataClassification ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Internet exposed</span><select name="internetExposed" required><option value="true" ' + (onboardingDraft.internetExposed === "true" ? "selected" : "") + '>Yes</option><option value="false" ' + (onboardingDraft.internetExposed === "false" ? "selected" : "") + '>No</option></select></label><label class="wide"><span>Program goal</span><select name="programGoal" required><option value="none" ' + (onboardingDraft.programGoal === "none" ? "selected" : "") + '>No Assurance Goal Yet</option><option value="readiness" ' + (onboardingDraft.programGoal === "readiness" ? "selected" : "") + '>Program Readiness</option><option value="type-1" ' + (onboardingDraft.programGoal === "type-1" ? "selected" : "") + '>SOC 2 Type 1</option><option value="type-2" ' + (onboardingDraft.programGoal === "type-2" ? "selected" : "") + '>SOC 2 Type 2</option></select><small>This records management intent only. It does not create an engagement or establish the formal report period.</small></label></form><p class="onboarding-write-note">' + esc(existing) + ' Save draft marks the service Planned and In scope. It is selected for scope review, but it is not approved or active. ' + (state.repository?.mode === "trunk" ? "The browser saves and synchronizes the related files together." : "Manual mode leaves the files for you to commit.") + ' Complete the remaining Step 1 pages next.</p>';
|
|
1573
1616
|
}
|
|
1574
1617
|
|
|
1575
1618
|
function captureOnboardingForm() {
|
|
@@ -2562,10 +2605,35 @@ async function responseMessage(response) {
|
|
|
2562
2605
|
try { return JSON.parse(source).error || source; } catch { return source; }
|
|
2563
2606
|
}
|
|
2564
2607
|
async function localFetch(url, options) {
|
|
2608
|
+
const method = String(options?.method || "GET").toUpperCase();
|
|
2609
|
+
const synchronizing = state?.repository?.mode === "trunk"
|
|
2610
|
+
&& ["POST", "PUT", "DELETE"].includes(method)
|
|
2611
|
+
&& url !== "/api/evidence-packet";
|
|
2612
|
+
const chip = synchronizing ? document.querySelector(".repo-chip") : null;
|
|
2613
|
+
const previousChip = chip?.innerHTML;
|
|
2614
|
+
let repositoryRefreshed = false;
|
|
2615
|
+
if (chip) chip.innerHTML = '<span class="status-dot neutral"></span>Syncing';
|
|
2565
2616
|
try {
|
|
2566
|
-
|
|
2617
|
+
const response = await fetch(url, options);
|
|
2618
|
+
if (synchronizing && !response.ok) {
|
|
2619
|
+
try {
|
|
2620
|
+
const stateResponse = await fetch("/api/state");
|
|
2621
|
+
if (stateResponse.ok) {
|
|
2622
|
+
state = await stateResponse.json();
|
|
2623
|
+
if (chip?.isConnected) {
|
|
2624
|
+
chip.innerHTML = '<span class="status-dot ' + repositoryStatusTone(state.repository.status) + '"></span>' + esc(state.repository.label);
|
|
2625
|
+
repositoryRefreshed = true;
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
} catch {
|
|
2629
|
+
// The original response contains the useful mutation error.
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
return response;
|
|
2567
2633
|
} catch {
|
|
2568
2634
|
throw new Error("The filegrc server is unavailable. Restart npm run serve, or pnpm dev in the monorepo, and try again.");
|
|
2635
|
+
} finally {
|
|
2636
|
+
if (!repositoryRefreshed && chip?.isConnected && previousChip) chip.innerHTML = previousChip;
|
|
2569
2637
|
}
|
|
2570
2638
|
}
|
|
2571
2639
|
async function fetchJson(url, options) { const response = await localFetch(url, options); if (!response.ok) throw new Error(await responseMessage(response)); return response.json(); }
|
|
@@ -2598,7 +2666,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
2598
2666
|
.readiness-map{margin:14px 0;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:20px 22px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.readiness-map-head{display:grid;grid-template-columns:minmax(220px,1fr) minmax(320px,420px);gap:28px;align-items:center;margin-bottom:17px}.readiness-map-head h3{font-size:18px;margin:5px 0 0}.readiness-progress-summary{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 14px;align-items:center}.readiness-progress-summary>div{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 12px;align-items:baseline}.readiness-progress-summary>div>span{color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}.readiness-progress-summary>div>strong{font-size:9.6px;font-weight:700;line-height:1.2}.readiness-progress-summary .progress,.readiness-progress-summary small{grid-column:1/-1}.readiness-progress-summary small{color:var(--muted);font-size:9.6px}.readiness-progress-summary>.button{white-space:nowrap}.readiness-flow{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.readiness-flow a{display:grid;grid-template-columns:23px minmax(0,1fr);column-gap:8px;align-content:start;min-width:0;padding:11px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft);text-decoration:none}.readiness-flow a:hover{border-color:var(--accent-light);background:var(--accent-soft)}.readiness-flow a>span{grid-row:1/4;display:grid;place-items:center;width:23px;height:23px;border-radius:50%;background:var(--primary-gradient);color:#fff;font-size:9.6px;font-weight:800}.readiness-flow strong{font-size:12px;line-height:1.25}.readiness-flow small{grid-column:2;color:var(--muted);font-size:9.6px;line-height:1.4;margin-top:3px}.readiness-state{grid-column:2;justify-self:start;margin-top:8px;padding:3px 6px;border-radius:99px;background:var(--surface-muted);color:var(--muted);font-size:8.4px;line-height:1.2}.readiness-state.good{background:#dcefe4;color:#125733}.readiness-state.warn{background:#f6e8c9;color:#79500f}.readiness-state.bad{background:#f7dfdc;color:#873027}.audit-engagement{display:grid;grid-template-columns:minmax(210px,1fr) minmax(260px,1.25fr) auto;gap:20px;align-items:center;padding:14px 15px;border-radius:8px;background:var(--surface-soft)}.audit-engagement strong{font-size:13.2px}.audit-engagement p,.audit-engagement li{color:var(--muted);font-size:10.8px;line-height:1.5}.audit-engagement p{margin:5px 0 0}.audit-engagement ul{margin:0;padding-left:18px}.audit-engagement .button{white-space:nowrap;text-decoration:none}.resource-directory{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.resource-directory>section{min-width:0;padding:12px;border-radius:8px;background:var(--surface-soft)}.resource-directory h4{margin:0 0 7px;color:var(--muted);font-size:10.8px;text-transform:uppercase;letter-spacing:.08em}.resource-directory a{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-top:1px solid var(--line);font-size:10.8px;text-decoration:none}.resource-directory a:first-of-type{border-top:0}.resource-directory a:hover span{color:var(--accent)}.resource-directory a strong{color:var(--muted);font-size:9.6px}.record-prose{max-width:790px}.record-prose section{padding:0 0 20px}.record-prose section+section{padding-top:20px;border-top:1px solid var(--line)}.record-prose h3{margin:0 0 7px;color:var(--muted);font-size:10.8px;text-transform:uppercase;letter-spacing:.08em}.record-prose p{margin:0;font-size:16.8px;line-height:1.65;white-space:pre-wrap}.connections-panel .panel-head>span{display:grid;place-items:center;min-width:22px;height:22px;border-radius:99px;background:var(--surface-muted);color:var(--muted);font-size:9.6px}.connections{display:grid}.connections a{display:block;padding:9px 0;border-top:1px solid var(--line);text-decoration:none}.connections a:first-child{padding-top:0;border-top:0}.connections strong,.connections small{display:block}.connections strong{font-size:12px}.connections small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.connections a:hover strong{color:var(--accent)}.connections-more{margin:9px 0 0;color:var(--muted);font-size:9.6px;line-height:1.4}.external-source{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;color:var(--accent);text-decoration:none}.external-source span,.external-source strong,.external-source small{display:block}.external-source strong{font-size:12px;line-height:1.35}.external-source small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.35;overflow-wrap:anywhere}.external-source b{font-size:13.2px}.external-source:hover strong{text-decoration:underline}
|
|
2599
2667
|
.page-title-line{display:flex;align-items:center;gap:8px}.guide-trigger{display:grid;place-items:center;width:24px;height:24px;flex:0 0 auto;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--panel);color:var(--muted);cursor:pointer}.guide-trigger svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round}.guide-trigger:hover{border-color:var(--accent-light);color:var(--accent)}.guide-trigger:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.page-guide{display:grid;grid-template-columns:1.05fr 1.25fr 1fr;gap:0;margin:0;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.resource-guide-popover{position:fixed;z-index:40;overflow:auto;box-shadow:0 18px 50px rgba(0,0,24,.24)}.resource-guide-popover[hidden]{display:none}.page-guide>div{padding:14px 16px;border-left:1px solid var(--line);min-width:0}.page-guide>div:first-child{border-left:0}.page-guide>div>span{display:block;color:var(--accent);text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780;margin-bottom:6px}.page-guide p{color:var(--muted);font-size:12px;line-height:1.5;margin:0}.guide-links{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.guide-links a{color:var(--accent);background:var(--accent-soft);border-radius:99px;padding:4px 7px;text-decoration:none;font-size:9.6px;font-weight:700}
|
|
2600
2668
|
.operation-tracking{display:grid;gap:2px;min-width:0;text-decoration:none}.operation-tracking strong,.operation-tracking small{display:block;overflow-wrap:anywhere}.operation-tracking small{color:var(--muted);line-height:1.35}.operation-tracking.running strong{color:#176143}.operation-tracking.waiting strong,.operation-tracking.mixed strong{color:var(--amber)}.operation-tracking.paused strong{color:var(--red)}a.operation-tracking:hover strong{text-decoration:underline}
|
|
2601
|
-
.page-actions{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap}.repository-sync-status{min-height:18px;margin:7px 0 0;color:var(--muted);font-size:13.2px}.repository-sync-status.error{color:var(--red)}.onboarding-dialog{width:min(470px,calc(100vw - 30px));max-height:calc(100vh - 32px);margin:0;border:1px solid var(--line);border-radius:13px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 28px 90px rgba(0,0,24,.38);overflow:hidden}.onboarding-dialog[open]{display:flex;flex-direction:column}.onboarding-dialog::backdrop{background:transparent;backdrop-filter:none}.onboarding-shade{position:fixed;inset:0;z-index:60;pointer-events:none}.onboarding-shade span{position:absolute;background:rgba(0,0,24,.58)}.onboarding-progress{display:grid;flex:0 0 auto;grid-template-columns:repeat(var(--onboarding-step-count),1fr);gap:5px;padding:18px 24px 0}.onboarding-progress span{height:3px;border-radius:3px;background:var(--surface-muted)}.onboarding-progress span.active{background:var(--accent-light)}.onboarding-scroll{min-height:0;overflow-y:auto}.onboarding-head{padding:22px 25px 0}.onboarding-head h2{font-family:Georgia,serif;font-size:30px;font-weight:500;letter-spacing:-.015em;margin:8px 0 0}.onboarding-body{color:var(--muted);font-size:14.4px;line-height:1.6;margin:13px 25px 0}.onboarding-body+.onboarding-body{margin-top:8px}.onboarding-points{display:grid;gap:9px;margin:18px 25px 4px;padding-left:19px}.onboarding-points li{font-size:13.2px;line-height:1.5;padding-left:3px}.onboarding-sections{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 25px 4px}.onboarding-sections section{padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.onboarding-sections strong{font-size:13.2px}.onboarding-sections p{margin:6px 0 0;color:var(--muted);font-size:12px;line-height:1.5}.onboarding-actions{flex:0 0 auto;padding:12px 25px 23px;border-top:1px solid var(--line);background:var(--panel)}.onboarding-skip{margin-right:auto;color:var(--muted);text-transform:none;letter-spacing:0;font-size:13.2px}.onboarding-form{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin:18px 25px 0}.onboarding-form label{display:block;min-width:0}.onboarding-form label.wide{grid-column:1/-1}.onboarding-form label>span{display:block;color:var(--ink);font-size:12px;font-weight:720;margin-bottom:6px}.onboarding-form input,.onboarding-form select,.onboarding-form textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.onboarding-form textarea{min-height:78px;resize:vertical}.onboarding-form small{display:block;color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:5px}.onboarding-write-note{color:var(--muted);font-size:10.8px;line-height:1.5;margin:12px 25px 16px}.onboarding-scroll>.dialog-error{margin:8px 25px 0}.onboarding-focus{outline:4px solid var(--accent-light)!important;outline-offset:5px;scroll-margin-top:102px}
|
|
2669
|
+
.page-actions{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap}.repository-sync-status{min-height:18px;margin:7px 0 0;color:var(--muted);font-size:13.2px}.repository-sync-status.error{color:var(--red)}.repository-state-banner,.repository-override{display:flex;align-items:flex-start;gap:13px;margin-bottom:14px}.repository-state-banner>.status-dot,.repository-override>.status-dot{margin-top:5px}.repository-state-banner h3{margin:3px 0 5px}.repository-state-banner p:last-child,.repository-override p{margin:0;color:var(--muted);line-height:1.5}.repository-override{padding:14px 17px;border:1px solid #e9c888;border-radius:9px;background:#fff8e8;font-size:13.2px}.repository-override strong{display:block;margin-bottom:4px}.onboarding-dialog{width:min(470px,calc(100vw - 30px));max-height:calc(100vh - 32px);margin:0;border:1px solid var(--line);border-radius:13px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 28px 90px rgba(0,0,24,.38);overflow:hidden}.onboarding-dialog[open]{display:flex;flex-direction:column}.onboarding-dialog::backdrop{background:transparent;backdrop-filter:none}.onboarding-shade{position:fixed;inset:0;z-index:60;pointer-events:none}.onboarding-shade span{position:absolute;background:rgba(0,0,24,.58)}.onboarding-progress{display:grid;flex:0 0 auto;grid-template-columns:repeat(var(--onboarding-step-count),1fr);gap:5px;padding:18px 24px 0}.onboarding-progress span{height:3px;border-radius:3px;background:var(--surface-muted)}.onboarding-progress span.active{background:var(--accent-light)}.onboarding-scroll{min-height:0;overflow-y:auto}.onboarding-head{padding:22px 25px 0}.onboarding-head h2{font-family:Georgia,serif;font-size:30px;font-weight:500;letter-spacing:-.015em;margin:8px 0 0}.onboarding-body{color:var(--muted);font-size:14.4px;line-height:1.6;margin:13px 25px 0}.onboarding-body+.onboarding-body{margin-top:8px}.onboarding-points{display:grid;gap:9px;margin:18px 25px 4px;padding-left:19px}.onboarding-points li{font-size:13.2px;line-height:1.5;padding-left:3px}.onboarding-sections{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 25px 4px}.onboarding-sections section{padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.onboarding-sections strong{font-size:13.2px}.onboarding-sections p{margin:6px 0 0;color:var(--muted);font-size:12px;line-height:1.5}.onboarding-actions{flex:0 0 auto;padding:12px 25px 23px;border-top:1px solid var(--line);background:var(--panel)}.onboarding-skip{margin-right:auto;color:var(--muted);text-transform:none;letter-spacing:0;font-size:13.2px}.onboarding-form{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin:18px 25px 0}.onboarding-form label{display:block;min-width:0}.onboarding-form label.wide{grid-column:1/-1}.onboarding-form label>span{display:block;color:var(--ink);font-size:12px;font-weight:720;margin-bottom:6px}.onboarding-form input,.onboarding-form select,.onboarding-form textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.onboarding-form textarea{min-height:78px;resize:vertical}.onboarding-form small{display:block;color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:5px}.onboarding-write-note{color:var(--muted);font-size:10.8px;line-height:1.5;margin:12px 25px 16px}.onboarding-scroll>.dialog-error{margin:8px 25px 0}.onboarding-focus{outline:4px solid var(--accent-light)!important;outline-offset:5px;scroll-margin-top:102px}
|
|
2602
2670
|
.page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}
|
|
2603
2671
|
@media(max-width:1200px){.readiness-flow{grid-template-columns:repeat(3,minmax(0,1fr))}.audit-engagement{grid-template-columns:1fr 1fr}.audit-engagement .button{grid-column:1/-1;justify-self:start}}
|
|
2604
2672
|
@media(max-width:1100px){.search{display:none}.topbar-status{margin-left:auto}.metrics{grid-template-columns:repeat(2,1fr)}.dashboard-grid,.organization-grid{grid-template-columns:repeat(2,1fr)}.catalog{grid-template-columns:repeat(3,1fr)}.span-2{grid-column:span 2}.resource-directory{grid-template-columns:repeat(2,minmax(0,1fr))}}
|