copilot-operator 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/dist/src/api/app.module.js +18 -0
  4. package/dist/src/api/dataAcl.js +109 -0
  5. package/dist/src/api/folderPicker.js +110 -0
  6. package/dist/src/api/main.js +132 -0
  7. package/dist/src/api/operator.controller.js +975 -0
  8. package/dist/src/api/operator.service.js +1971 -0
  9. package/dist/src/api/saveToDesktop.js +157 -0
  10. package/dist/src/api/security.js +225 -0
  11. package/dist/src/api/settings.js +57 -0
  12. package/dist/src/cli.js +531 -0
  13. package/dist/src/config/layout.js +66 -0
  14. package/dist/src/config/lockedPolicy.js +176 -0
  15. package/dist/src/config/schema.js +487 -0
  16. package/dist/src/context/contextFiles.js +295 -0
  17. package/dist/src/context/desktopMirror.js +113 -0
  18. package/dist/src/context/projectMirror.js +364 -0
  19. package/dist/src/exec/authorizer.js +61 -0
  20. package/dist/src/exec/checks.js +285 -0
  21. package/dist/src/exec/confinement.js +224 -0
  22. package/dist/src/exec/dangerous.js +161 -0
  23. package/dist/src/exec/environment.js +80 -0
  24. package/dist/src/exec/isolation.js +166 -0
  25. package/dist/src/exec/network.js +242 -0
  26. package/dist/src/exec/policy.js +201 -0
  27. package/dist/src/exec/policyManifest.js +84 -0
  28. package/dist/src/exec/processes.js +329 -0
  29. package/dist/src/exec/programs.js +290 -0
  30. package/dist/src/exec/redaction.js +93 -0
  31. package/dist/src/exec/reportFile.js +154 -0
  32. package/dist/src/exec/runner.js +245 -0
  33. package/dist/src/exec/scriptFiles.js +81 -0
  34. package/dist/src/exec/shellExecuteTrap.js +221 -0
  35. package/dist/src/exec/shells.js +298 -0
  36. package/dist/src/exec/stepEnv.js +72 -0
  37. package/dist/src/exec/winps.js +17 -0
  38. package/dist/src/exec/workDir.js +94 -0
  39. package/dist/src/log/runLog.js +43 -0
  40. package/dist/src/orchestrator/derivedChecks.js +95 -0
  41. package/dist/src/orchestrator/review.js +590 -0
  42. package/dist/src/orchestrator/taskRunner.js +1582 -0
  43. package/dist/src/plan/brief.js +1785 -0
  44. package/dist/src/plan/importPlan.js +202 -0
  45. package/dist/src/plan/schema.js +483 -0
  46. package/dist/src/plan/script.js +1012 -0
  47. package/dist/src/plan/systemGuide.js +1851 -0
  48. package/dist/src/protocol/parser.js +224 -0
  49. package/dist/src/protocol/replySchema.js +207 -0
  50. package/dist/src/protocol/reporter.js +75 -0
  51. package/dist/src/protocol/reviewSchema.js +205 -0
  52. package/dist/src/session/compose.js +67 -0
  53. package/dist/src/session/debugExport.js +165 -0
  54. package/dist/src/session/events.js +34 -0
  55. package/dist/src/session/exportRecord.js +261 -0
  56. package/dist/src/session/exports.js +484 -0
  57. package/dist/src/session/model.js +22 -0
  58. package/dist/src/session/retention.js +34 -0
  59. package/dist/src/session/runName.js +36 -0
  60. package/dist/src/session/store.js +440 -0
  61. package/dist/src/session/story.js +198 -0
  62. package/dist/src/transport/acceptance.js +34 -0
  63. package/dist/src/transport/chatSession.js +73 -0
  64. package/dist/src/transport/copilotTransport.js +1208 -0
  65. package/dist/src/transport/edgeCrash.js +59 -0
  66. package/dist/src/transport/locators.js +261 -0
  67. package/dist/src/transport/profileLock.js +102 -0
  68. package/dist/src/util/pacing.js +89 -0
  69. package/dist/src/vcs/commitHygiene.js +102 -0
  70. package/dist/src/vcs/git.js +286 -0
  71. package/dist/src/vcs/taskVcs.js +343 -0
  72. package/dist/web/404.html +15 -0
  73. package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
  74. package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
  75. package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
  76. package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
  77. package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
  78. package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
  79. package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
  80. package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
  81. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
  82. package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
  83. package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
  84. package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
  85. package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
  86. package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
  87. package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
  88. package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
  89. package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
  90. package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
  91. package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
  92. package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
  93. package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
  94. package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
  95. package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
  96. package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
  97. package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  98. package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
  99. package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
  100. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
  101. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
  102. package/dist/web/appearance.html +15 -0
  103. package/dist/web/appearance.txt +26 -0
  104. package/dist/web/defaults.html +15 -0
  105. package/dist/web/defaults.txt +26 -0
  106. package/dist/web/history.html +15 -0
  107. package/dist/web/history.txt +26 -0
  108. package/dist/web/import.html +15 -0
  109. package/dist/web/import.txt +26 -0
  110. package/dist/web/index.html +15 -0
  111. package/dist/web/index.txt +26 -0
  112. package/dist/web/level1.html +15 -0
  113. package/dist/web/level1.txt +26 -0
  114. package/dist/web/presets.html +18 -0
  115. package/dist/web/presets.txt +26 -0
  116. package/dist/web/sessions/view.html +15 -0
  117. package/dist/web/sessions/view.txt +26 -0
  118. package/dist/web/system.html +15 -0
  119. package/dist/web/system.txt +26 -0
  120. package/package.json +111 -0
  121. package/prompts/level1.md +535 -0
  122. package/prompts/organisation.bg.md +44 -0
  123. package/prompts/organisation.en.md +44 -0
  124. package/prompts/persona.bg.md +17 -0
  125. package/prompts/persona.en.md +17 -0
  126. package/prompts/review1.md +277 -0
  127. package/prompts/work.bg.md +19 -0
  128. package/prompts/work.en.md +19 -0
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Mirrors selected parts of a project into one flat folder on the Desktop.
3
+ *
4
+ * Why flat, and why renamed. The folder is meant to sit inside a OneDrive-backed Desktop so
5
+ * the copies reach the cloud with no user action, and the chat's upload input rejects source
6
+ * extensions outright. So the directory structure cannot be kept as folders and the
7
+ * extensions cannot be kept as-is. Both problems are solved by encoding the path into the
8
+ * file name and giving everything a `.txt` tail:
9
+ *
10
+ * src/test/example-test.spec.ts -> src--test--example-test.spec.ts.txt
11
+ *
12
+ * The convention is taken from the user's own Context Picker
13
+ * (https://github.com/ivan-markov-666/context-picker, MIT), specifically
14
+ * `copySelectionToDir` in `src/scan-core.ts`, so that a folder produced here and a folder
15
+ * produced by the extension are interchangeable:
16
+ *
17
+ * - relative path, forward slashes, joined with the separator
18
+ * - name collisions get `<sep><n>` appended, starting at 2, compared case-insensitively
19
+ * - `.txt` appended last, after the collision suffix
20
+ * - sync mode writes only files whose bytes differ and deletes what is no longer selected
21
+ *
22
+ * The difference from the extension is the input. There the user ticks individual files in
23
+ * an editor UI. Here the user names the project root and the directories to include, and
24
+ * selecting a directory takes everything beneath it.
25
+ */
26
+ import { readFile, writeFile, readdir, stat, mkdir, rm } from 'node:fs/promises';
27
+ import { join, relative, resolve, sep as osSep, basename, extname } from 'node:path';
28
+ import ignore from 'ignore';
29
+ /**
30
+ * Directories that are never useful as chat context, pruned at any depth even when there is
31
+ * no `.gitignore`. Same list as Context Picker's `DEFAULT_IGNORE`, plus the obvious
32
+ * JavaScript and Python build output.
33
+ */
34
+ export const DEFAULT_IGNORE_DIRS = [
35
+ 'node_modules',
36
+ '.git',
37
+ 'bin',
38
+ 'obj',
39
+ '.vs',
40
+ '.idea',
41
+ 'dist',
42
+ 'build',
43
+ 'out',
44
+ 'coverage',
45
+ '__pycache__',
46
+ '.venv',
47
+ 'venv',
48
+ 'target',
49
+ ];
50
+ export const DEFAULT_SEPARATOR = '--';
51
+ export const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
52
+ /** `.\Src\Foo\` and `src/foo` are the same directory. This is the form both lists compare in. */
53
+ export function normalizeDirPath(dir) {
54
+ const cleaned = dir
55
+ .trim()
56
+ .replace(/\\/g, '/')
57
+ .replace(/^\.\//, '')
58
+ .replace(/\/+$/, '');
59
+ return cleaned === '' ? '.' : cleaned;
60
+ }
61
+ function isUnder(child, parent) {
62
+ if (parent === '.')
63
+ return child !== '.';
64
+ return child.toLowerCase().startsWith(`${parent.toLowerCase()}/`);
65
+ }
66
+ /** Every contradiction between the two lists, so a UI can refuse to save and say why. */
67
+ export function findSelectionConflicts(includeDirs, excludeDirs) {
68
+ const includes = includeDirs.map(normalizeDirPath).filter(Boolean);
69
+ const excludes = excludeDirs.map(normalizeDirPath).filter(Boolean);
70
+ const out = [];
71
+ for (const inc of includes) {
72
+ for (const exc of excludes) {
73
+ if (inc.toLowerCase() === exc.toLowerCase()) {
74
+ out.push({ include: inc, exclude: exc, kind: 'same' });
75
+ }
76
+ else if (isUnder(inc, exc)) {
77
+ out.push({ include: inc, exclude: exc, kind: 'excluded-parent' });
78
+ }
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+ /** One sentence per conflict, for a message box or a thrown error. */
84
+ export function describeConflicts(conflicts) {
85
+ return conflicts
86
+ .map((c) => c.kind === 'same'
87
+ ? `"${c.include}" is in both the include and the exclude list.`
88
+ : `"${c.exclude}" is excluded but "${c.include}" is included, and lies under it.`)
89
+ .join(' ');
90
+ }
91
+ /** Files the program owns in the target folder and must never delete as "stale". */
92
+ const RESERVED_TARGET_NAMES = new Set(['.cop-manifest.json']);
93
+ /**
94
+ * Files that hold secrets by convention, never copied to the Desktop unless the operator says so.
95
+ * `.env` was the only one until 2026-09-27; a key file, an `.npmrc` with a registry token or a
96
+ * `credentials.json` are secrets just the same, and the Desktop is on its way to OneDrive.
97
+ */
98
+ function isEnvFile(path) {
99
+ const name = basename(path).toLowerCase();
100
+ if (name === '.env' || name.startsWith('.env.'))
101
+ return true;
102
+ if (/\.(pem|pfx|p12|key|jks|keystore|kdbx|ppk)$/.test(name))
103
+ return true;
104
+ if (/^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/.test(name))
105
+ return true;
106
+ return ['.npmrc', '.netrc', '.pypirc', 'credentials.json', 'secrets.json', 'service-account.json', '.htpasswd'].includes(name)
107
+ || /(^|\.)secrets?\.(json|ya?ml|toml|xml)$/.test(name);
108
+ }
109
+ /**
110
+ * Flattens a relative path into a single file name.
111
+ *
112
+ * `used` carries the names already taken, lower-cased, and is mutated. Collisions are
113
+ * possible because two different paths can flatten to the same string once separators are
114
+ * substituted, so the suffix is not optional.
115
+ */
116
+ export function flattenName(relPath, used, separator = DEFAULT_SEPARATOR, txtMode = 'append') {
117
+ const normalized = relPath.split(/[\\/]/).join(separator);
118
+ let candidate = normalized;
119
+ let i = 2;
120
+ while (used.has(candidate.toLowerCase())) {
121
+ candidate = `${normalized}${separator}${i}`;
122
+ i += 1;
123
+ }
124
+ used.add(candidate.toLowerCase());
125
+ switch (txtMode) {
126
+ case 'append':
127
+ return `${candidate}.txt`;
128
+ case 'replace': {
129
+ const ext = extname(candidate);
130
+ return ext ? `${candidate.slice(0, -ext.length)}.txt` : `${candidate}.txt`;
131
+ }
132
+ case 'none':
133
+ return candidate;
134
+ }
135
+ }
136
+ async function loadGitignore(rootDir, respect) {
137
+ if (!respect)
138
+ return null;
139
+ try {
140
+ const content = await readFile(join(rootDir, '.gitignore'), 'utf8');
141
+ return ignore().add(content);
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ /**
148
+ * Depth-first walk of one directory, returning file paths relative to `rootDir`.
149
+ *
150
+ * Two rules about `.env` files are enforced here, and nowhere else:
151
+ *
152
+ * - `.gitignore` never decides an env file. Almost every project ignores `.env`, so letting
153
+ * the gitignore option hide them would make the env option meaningless. Env files are
154
+ * always handed to `collectFiles`, which keeps or drops them by the env option alone.
155
+ * - When env files are wanted, a directory that only `.gitignore` hides is still descended
156
+ * into, but nothing except env files is taken from it. That is how an env file inside an
157
+ * ignored folder still reaches the selection without dragging the folder along with it.
158
+ *
159
+ * `DEFAULT_IGNORE_DIRS` and the user's own exclusions are absolute: they are never descended
160
+ * into, env files or not.
161
+ */
162
+ async function walk(rootDir, dirRel, ig, ignoreDirs, excluded, out, opts) {
163
+ const abs = join(rootDir, dirRel);
164
+ let entries;
165
+ try {
166
+ entries = await readdir(abs, { withFileTypes: true });
167
+ }
168
+ catch {
169
+ return;
170
+ }
171
+ for (const entry of entries) {
172
+ const childRel = dirRel === '.' ? entry.name : `${dirRel}/${entry.name}`;
173
+ const posixRel = childRel.split(osSep).join('/');
174
+ if (entry.isDirectory()) {
175
+ if (ignoreDirs.has(entry.name.toLowerCase()))
176
+ continue;
177
+ if (excluded.has(posixRel.toLowerCase()))
178
+ continue;
179
+ const gitignored = opts.inGitignored === true || (ig?.ignores(`${posixRel}/`) ?? false);
180
+ if (gitignored && !opts.includeEnvFiles)
181
+ continue;
182
+ await walk(rootDir, childRel, ig, ignoreDirs, excluded, out, { ...opts, inGitignored: gitignored });
183
+ }
184
+ else if (entry.isFile()) {
185
+ if (isEnvFile(entry.name)) {
186
+ out.push(posixRel);
187
+ continue;
188
+ }
189
+ if (opts.inGitignored)
190
+ continue;
191
+ if (ig?.ignores(posixRel))
192
+ continue;
193
+ out.push(posixRel);
194
+ }
195
+ }
196
+ }
197
+ /**
198
+ * Lists the directories under `rootDir` that are worth offering for selection, so a CLI or
199
+ * a UI can present the same kind of choice the extension's checkbox tree does. Pruned by the
200
+ * same rules as the mirror itself, so what is listed is what can actually be copied.
201
+ */
202
+ export async function listSelectableDirs(rootDir, opts = {}) {
203
+ const ig = await loadGitignore(resolve(rootDir), opts.respectGitignore ?? true);
204
+ const ignoreDirs = new Set([...DEFAULT_IGNORE_DIRS, ...(opts.ignoreDirs ?? [])].map((d) => d.toLowerCase()));
205
+ const maxDepth = opts.maxDepth ?? 4;
206
+ const found = [];
207
+ const recurse = async (dirRel, depth) => {
208
+ if (depth > maxDepth)
209
+ return;
210
+ let entries;
211
+ try {
212
+ entries = await readdir(join(resolve(rootDir), dirRel), { withFileTypes: true });
213
+ }
214
+ catch {
215
+ return;
216
+ }
217
+ for (const entry of entries) {
218
+ if (!entry.isDirectory())
219
+ continue;
220
+ if (ignoreDirs.has(entry.name.toLowerCase()))
221
+ continue;
222
+ const childRel = dirRel === '.' ? entry.name : `${dirRel}/${entry.name}`;
223
+ if (ig?.ignores(`${childRel}/`))
224
+ continue;
225
+ found.push(childRel);
226
+ await recurse(childRel, depth + 1);
227
+ }
228
+ };
229
+ await recurse('.', 1);
230
+ return found.sort();
231
+ }
232
+ /**
233
+ * Collects the files the configuration selects, as paths relative to `rootDir`, sorted and
234
+ * de-duplicated. Exported separately so a caller can preview a selection without writing
235
+ * anything.
236
+ */
237
+ export async function collectFiles(cfg) {
238
+ const rootDir = resolve(cfg.rootDir);
239
+ if (cfg.includeDirs.length === 0) {
240
+ throw new Error('includeDirs is empty: nothing would be copied. Select at least one directory.');
241
+ }
242
+ const conflicts = findSelectionConflicts(cfg.includeDirs, cfg.excludeDirs ?? []);
243
+ if (conflicts.length > 0) {
244
+ throw new Error(`The include and exclude lists contradict each other. ${describeConflicts(conflicts)}`);
245
+ }
246
+ const includeEnvFiles = cfg.includeEnvFiles ?? false;
247
+ const ig = await loadGitignore(rootDir, cfg.respectGitignore ?? true);
248
+ const ignoreDirs = new Set([...DEFAULT_IGNORE_DIRS, ...(cfg.ignoreDirs ?? [])].map((d) => d.toLowerCase()));
249
+ const excluded = new Set((cfg.excludeDirs ?? []).map((d) => normalizeDirPath(d).toLowerCase()));
250
+ const collected = new Set();
251
+ const skipped = [];
252
+ for (const dir of cfg.includeDirs) {
253
+ const dirRel = normalizeDirPath(dir);
254
+ const abs = join(rootDir, dirRel);
255
+ const s = await stat(abs).catch(() => null);
256
+ if (!s?.isDirectory()) {
257
+ skipped.push({ relPath: dirRel, reason: 'not a directory under the project root' });
258
+ continue;
259
+ }
260
+ const found = [];
261
+ await walk(rootDir, dirRel, ig, ignoreDirs, excluded, found, { includeEnvFiles });
262
+ for (const f of found)
263
+ collected.add(f);
264
+ }
265
+ const maxBytes = cfg.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
266
+ const files = [];
267
+ for (const rel of [...collected].sort()) {
268
+ if (!includeEnvFiles && isEnvFile(rel)) {
269
+ skipped.push({ relPath: rel, reason: 'env file, excluded to protect secrets' });
270
+ continue;
271
+ }
272
+ const s = await stat(join(rootDir, rel)).catch(() => null);
273
+ if (!s)
274
+ continue;
275
+ if (s.size > maxBytes) {
276
+ skipped.push({ relPath: rel, reason: `larger than ${Math.round(maxBytes / 1024)} KB` });
277
+ continue;
278
+ }
279
+ files.push(rel);
280
+ }
281
+ return { files, skipped };
282
+ }
283
+ /**
284
+ * Brings `targetDir` in line with the selection.
285
+ *
286
+ * Incremental by construction: a file is written only when its bytes differ from what is
287
+ * already there, and target files that are no longer part of the selection are deleted.
288
+ * Unchanged files are left untouched, which is what keeps OneDrive from re-uploading a whole
289
+ * project every time one file is edited.
290
+ */
291
+ export async function mirrorProject(cfg) {
292
+ const rootDir = resolve(cfg.rootDir);
293
+ const targetDir = resolve(cfg.targetDir);
294
+ const separator = cfg.separator ?? DEFAULT_SEPARATOR;
295
+ const txtMode = cfg.txtMode ?? 'append';
296
+ const { files, skipped } = await collectFiles(cfg);
297
+ await mkdir(targetDir, { recursive: true });
298
+ const used = new Set();
299
+ const desired = new Set();
300
+ const mapping = {};
301
+ const added = [];
302
+ const updated = [];
303
+ const unchanged = [];
304
+ let totalBytes = 0;
305
+ for (const rel of files) {
306
+ const targetName = flattenName(cfg.namePrefix ? `${cfg.namePrefix}/${rel}` : rel, used, separator, txtMode);
307
+ desired.add(targetName);
308
+ mapping[rel] = targetName;
309
+ const source = join(rootDir, rel);
310
+ const dest = join(targetDir, targetName);
311
+ const bytes = await readFile(source);
312
+ totalBytes += bytes.length;
313
+ const existing = await readFile(dest).catch(() => null);
314
+ if (existing === null) {
315
+ await writeFile(dest, bytes);
316
+ added.push(targetName);
317
+ }
318
+ else if (existing.equals(bytes)) {
319
+ unchanged.push(targetName);
320
+ }
321
+ else {
322
+ await writeFile(dest, bytes);
323
+ updated.push(targetName);
324
+ }
325
+ }
326
+ const deleted = [];
327
+ for (const entry of await readdir(targetDir).catch(() => [])) {
328
+ if (desired.has(entry) || RESERVED_TARGET_NAMES.has(entry))
329
+ continue;
330
+ await rm(join(targetDir, entry), { recursive: true, force: true });
331
+ deleted.push(entry);
332
+ }
333
+ return {
334
+ added: added.sort(),
335
+ updated: updated.sort(),
336
+ deleted: deleted.sort(),
337
+ unchanged: unchanged.sort(),
338
+ skipped,
339
+ mapping,
340
+ totalBytes,
341
+ };
342
+ }
343
+ /** One line for the console and the run log. */
344
+ export function describeMirror(r) {
345
+ const bits = [
346
+ `${r.added.length} added`,
347
+ `${r.updated.length} updated`,
348
+ `${r.deleted.length} deleted`,
349
+ `${r.unchanged.length} unchanged`,
350
+ ];
351
+ if (r.skipped.length)
352
+ bits.push(`${r.skipped.length} skipped`);
353
+ return bits.join(', ');
354
+ }
355
+ /** Recovers the original relative path from a mirrored file name. */
356
+ export function unflattenName(targetName, separator = DEFAULT_SEPARATOR, txtMode = 'append') {
357
+ let name = targetName;
358
+ if (txtMode === 'append' && name.toLowerCase().endsWith('.txt')) {
359
+ name = name.slice(0, -4);
360
+ }
361
+ return name.split(separator).join('/');
362
+ }
363
+ /** `relative()` re-exported for callers building configs from absolute paths. */
364
+ export { relative as relativePath };
@@ -0,0 +1,61 @@
1
+ import { staticCheck, describeStep } from './policy.js';
2
+ import { networkFetchReason, networkFetchRefusal } from './network.js';
3
+ import { scriptNetworkReason } from './policy.js';
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { stdin, stdout } from 'node:process';
6
+ /**
7
+ * Runs the static rules, then, unless unattended, asks the human through `ask`.
8
+ *
9
+ * A step that fetches from the network is the exception to "unless unattended": it is asked about
10
+ * in every mode, with `held` saying why, because the runs nobody is watching are exactly the ones
11
+ * in which a download followed by a second step that runs it would otherwise go unseen. `cfg` is
12
+ * read on every call, not captured, so a run switched to unattended half-way is judged as one from
13
+ * its next step on.
14
+ */
15
+ export function makeAuthorizer(cfg, ask) {
16
+ return {
17
+ async authorize(step, ctx) {
18
+ const blocked = staticCheck(step, cfg, undefined, ctx.confinement);
19
+ if (blocked)
20
+ return blocked;
21
+ const cwd = ctx.confinement?.cwd;
22
+ const network = networkFetchReason(step.cmd, cwd) ?? (cwd ? scriptNetworkReason(step.cmd, cwd) : null);
23
+ if (network)
24
+ return await ask(step, ctx, { network });
25
+ if (cfg.mode === 'unattended')
26
+ return { action: 'run' };
27
+ return await ask(step, ctx);
28
+ },
29
+ };
30
+ }
31
+ /** The terminal version: Enter runs, `s` skips, `q` aborts. */
32
+ export function terminalAuthorizer(cfg, print) {
33
+ return makeAuthorizer(cfg, async (step, _ctx, held) => {
34
+ print('');
35
+ print(` step ${step.id}: ${describeStep(step)}`);
36
+ if (held)
37
+ print(` held: ${held.network}`);
38
+ const rl = createInterface({ input: stdin, output: stdout });
39
+ try {
40
+ const answer = (await rl.question(' [Enter] run [s] skip [q] abort > ')).trim().toLowerCase();
41
+ if (answer === 'q')
42
+ return { action: 'abort', reason: 'aborted by the operator' };
43
+ if (answer === 's')
44
+ return { action: 'skip', reason: 'skipped by the operator' };
45
+ return { action: 'run' };
46
+ }
47
+ finally {
48
+ rl.close();
49
+ }
50
+ });
51
+ }
52
+ /**
53
+ * Everything runs after the static rules. Only for runs the user explicitly marked so.
54
+ *
55
+ * A step held for a person (a network fetch) is refused here, with the reason, because this form
56
+ * has nobody to ask. The web UI does not use it: its unattended runs keep the approval screen for
57
+ * exactly those steps.
58
+ */
59
+ export function unattendedAuthorizer(cfg) {
60
+ return makeAuthorizer({ ...cfg, mode: 'unattended' }, async (_step, _ctx, held) => held ? { action: 'skip', reason: networkFetchRefusal(held.network) } : { action: 'run' });
61
+ }