@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.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 (86) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +9 -3
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
@@ -16,14 +16,7 @@ export const ORG_CONTEXT = /^org:[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
16
16
  const INSTANT = /^[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:[0-5][0-9](?:\.[0-9]{1,3})?(?:[Zz]|[+-][0-9]{2}:[0-9]{2})$/;
17
17
  const SUB_MILLISECOND = /:[0-9]{2}\.[0-9]{4,}/;
18
18
 
19
- const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
20
- const isString = (value) => typeof value === 'string';
21
- const list = (value) => (Array.isArray(value) ? value.filter(isObject) : []);
22
- const indexed = (value) => (Array.isArray(value) ? value.flatMap((item, index) => (isObject(item) ? [[item, index]] : [])) : []);
23
- const show = (value) => {
24
- if (typeof value === 'string') return value;
25
- try { return String(value); } catch { return JSON.stringify(value) ?? typeof value; }
26
- };
19
+ import { isObject, isString, objects as list, indexed, show } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
27
20
  const finding = (code, instancePath, message) => ({ code, instancePath, message });
28
21
  const isOrg = (value) => isString(value) && value.startsWith('org:');
29
22
 
@@ -0,0 +1,400 @@
1
+ // Supervisors (DEC-0032, LC-14): systemd user units, Task Scheduler tasks and launchd plists, installed and removed without touching what the operator turned off; release pruning (LC-11, LC-15).
2
+ // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_supervisors.py. Node 20+, no dependency.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { credentialName, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
8
+ import { DRAIN_SECONDS, SUPERVISOR_ENV, ServiceError, TASK, UNIT } from './fabric-base.mjs';
9
+ import { atomicWrite, serviceDirs } from './fabric-paths.mjs';
10
+ import { SUPERVISOR_ARG, markerArguments } from './fabric-lock.mjs';
11
+ import { URI } from './fabric-backups.mjs';
12
+
13
+ export const DEFAULT_EXIT_TIMEOUT = 15;
14
+
15
+ /**
16
+ * Keep the release the job runs and the one before it; remove older release directories, newest
17
+ * first by modification time. `current` (a release or a symlink to one) is never removed, even
18
+ * after a rollback. Symlinks and files are left alone. Returns the removed paths.
19
+ */
20
+ export function pruneReleases(releasesDir, { keep = 2, current } = {}) {
21
+ if (keep < 2) throw new ServiceError('keep at least the current release and the one before it (keep >= 2).');
22
+ if (!fs.existsSync(releasesDir)) return [];
23
+ const releases = fs.readdirSync(releasesDir, { withFileTypes: true })
24
+ .filter((e) => e.isDirectory() && !e.isSymbolicLink())
25
+ .map((e) => path.join(releasesDir, e.name))
26
+ .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
27
+ const keepers = [];
28
+ if (current) {
29
+ const resolved = fs.realpathSync(current);
30
+ keepers.push(...releases.filter((p) => fs.realpathSync(p) === resolved));
31
+ }
32
+ for (const release of releases) {
33
+ if (keepers.length >= keep) break;
34
+ if (!keepers.includes(release)) keepers.push(release);
35
+ }
36
+ const removed = releases.filter((p) => !keepers.includes(p));
37
+ for (const release of removed) fs.rmSync(release, { recursive: true, force: true });
38
+ return removed;
39
+ }
40
+
41
+ // The twins of fabric_service.systemd_unit / task_xml and their install and uninstall. `run(argv)`
42
+ // returns spawnSync's {status, stdout, stderr}; the tests pass a recorder instead of the real tools.
43
+ const defaultRun = (argv) => spawnSync(argv[0], argv.slice(1), { encoding: 'utf8', windowsHide: true });
44
+
45
+ function plain(value, what) {
46
+ // A supervisor file is line- and XML-structured: no control character in anything written into it.
47
+ if (/[\x00-\x1f\x7f]/.test(value)) throw new ServiceError(`${what} holds a control character (a line break, a tab, a NUL); a supervisor file cannot carry it.`);
48
+ return value;
49
+ }
50
+
51
+ /** The supervisor file's writer writes the marker itself; one already in the arguments could only disagree with it. */
52
+ function noMarkerArgument(argv) {
53
+ if (markerArguments(argv).length) throw new ServiceError(`${SUPERVISOR_ARG} is written by the supervisor file's writer; leave it out of the program arguments.`);
54
+ }
55
+
56
+ function noSecretInSupervisorFile(argv, environment) {
57
+ // No supervisor file carries a secret (service.md): not in an argument, an environment name or its value.
58
+ for (const arg of argv) {
59
+ if (secretArgument(arg)) throw new ServiceError('An argument carries a credential; a supervisor file never does (read it from the token file or a secret reference).');
60
+ }
61
+ for (const [name, value] of Object.entries(environment ?? {})) {
62
+ if (credentialName(name) || secretValue(String(value))) throw new ServiceError(`Environment ${name} looks like a credential; a supervisor file never carries one.`);
63
+ }
64
+ }
65
+
66
+ function checkRun(result, what) {
67
+ if (result?.status !== 0) throw new ServiceError(`${what} failed: ${String(result?.stderr || result?.stdout || result?.error?.message || '').trim().slice(0, 200)}`);
68
+ }
69
+
70
+ const systemdQuote = (value) => `"${plain(value, 'An argument').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%').replace(/\$/g, '$$$$')}"`;
71
+
72
+ /**
73
+ * A systemd user unit (DEC-0032): Restart=always, RestartSec=10, TimeoutStopSec above the drain time,
74
+ * WantedBy=default.target. The service is told who supervises it (FABRIC_SERVICE_SUPERVISOR=systemd).
75
+ */
76
+ export function systemdUnit(unit, programArguments, { workingDirectory, environment = {}, description, stopTimeout = DEFAULT_EXIT_TIMEOUT } = {}) {
77
+ if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
78
+ const argv = programArguments.map(String);
79
+ if (!argv.length || !argv[0].startsWith('/')) throw new ServiceError('A systemd unit starts an absolute executable.');
80
+ if (!(stopTimeout > DRAIN_SECONDS)) throw new ServiceError(`TimeoutStopSec must outlast the ${DRAIN_SECONDS} s drain.`);
81
+ const env = { ...environment };
82
+ for (const name of Object.keys(env)) if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new ServiceError(`Environment name ${JSON.stringify(name)} is not a variable name.`);
83
+ noSecretInSupervisorFile(argv, env);
84
+ noMarkerArgument(programArguments.map(String));
85
+ env[SUPERVISOR_ENV] = 'systemd'; // the unit's writer writes the marker, whatever the caller passed
86
+ const cwd = plain(String(workingDirectory), 'The working directory');
87
+ if (!cwd.startsWith('/')) throw new ServiceError("A systemd unit's working directory is absolute.");
88
+ return [
89
+ '[Unit]', `Description=${plain(description ?? unit, 'The description').replace(/%/g, '%%')}`, '',
90
+ '[Service]', 'Type=simple', `ExecStart=${argv.map(systemdQuote).join(' ')}`,
91
+ // Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
92
+ `WorkingDirectory=${cwd.replace(/%/g, '%%')}`,
93
+ ...Object.keys(env).sort().map((k) => `Environment=${systemdQuote(`${k}=${env[k]}`)}`),
94
+ 'Restart=always', 'RestartSec=10', `TimeoutStopSec=${Math.trunc(stopTimeout)}`, 'KillMode=mixed', '',
95
+ '[Install]', 'WantedBy=default.target', '',
96
+ ].join('\n');
97
+ }
98
+
99
+ const systemdDir = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user');
100
+
101
+ /**
102
+ * Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
103
+ * The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so the
104
+ * new version is what starts once they enable it — unless `forceEnable` (the operator asked for it on).
105
+ */
106
+ export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defaultRun, platform = process.platform, forceEnable = false } = {}) {
107
+ if (platform !== 'linux') throw new ServiceError('systemd is the supervisor on Linux only.');
108
+ if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
109
+ fs.mkdirSync(unitDir, { recursive: true });
110
+ const file = path.join(unitDir, unit);
111
+ const state = fs.existsSync(file) ? String(run(['systemctl', '--user', 'is-enabled', unit])?.stdout ?? '').trim() : '';
112
+ atomicWrite(file, text, 0o644);
113
+ checkRun(run(['systemctl', '--user', 'daemon-reload']), 'systemctl --user daemon-reload');
114
+ if (state === 'disabled' && !forceEnable) return file;
115
+ checkRun(run(['systemctl', '--user', 'enable', unit]), 'systemctl --user enable');
116
+ checkRun(run(['systemctl', '--user', 'restart', unit]), 'systemctl --user restart');
117
+ return file;
118
+ }
119
+
120
+ /** Stop it and keep it off across logins (`disable --now`), then remove the unit. */
121
+ export function systemdUninstall(unit, { unitDir = systemdDir(), run = defaultRun, platform = process.platform } = {}) {
122
+ if (platform !== 'linux') throw new ServiceError('systemd is the supervisor on Linux only.');
123
+ if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
124
+ run(['systemctl', '--user', 'disable', '--now', unit]);
125
+ fs.rmSync(path.join(unitDir, unit), { force: true });
126
+ checkRun(run(['systemctl', '--user', 'daemon-reload']), 'systemctl --user daemon-reload');
127
+ }
128
+
129
+ /** One command line from arguments, quoted the way the Microsoft C runtime parses it (Python's list2cmdline). */
130
+ export function windowsCommandLine(args) {
131
+ return args.map((arg) => {
132
+ const quote = arg === '' || /[ \t]/.test(arg);
133
+ let out = quote ? '"' : '';
134
+ let slashes = 0;
135
+ for (const c of arg) {
136
+ if (c === '\\') { slashes += 1; continue; }
137
+ if (c === '"') { out += '\\'.repeat(slashes * 2 + 1) + '"'; slashes = 0; continue; }
138
+ out += '\\'.repeat(slashes) + c;
139
+ slashes = 0;
140
+ }
141
+ return out + '\\'.repeat(quote ? slashes * 2 : slashes) + (quote ? '"' : '');
142
+ }).join(' ');
143
+ }
144
+
145
+ const xml = (text) => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
146
+
147
+ /**
148
+ * A per-user Scheduled Task (DEC-0032): a logon trigger, restart on failure every minute, no execution
149
+ * time limit, run only when the user is logged on, created without administrator rights.
150
+ */
151
+ export function taskXml(task, programArguments, { workingDirectory, description, user } = {}) {
152
+ if (!TASK.test(task)) throw new ServiceError(`task must match ${TASK.source}`);
153
+ const argv = programArguments.map(String);
154
+ if (!argv.length || !/^[A-Za-z]:[\\/]/.test(argv[0])) throw new ServiceError('A task starts a drive-absolute executable (C:\\...).');
155
+ for (const arg of argv) plain(arg, 'An argument');
156
+ plain(String(workingDirectory), 'The working directory');
157
+ plain(description ?? task, 'The description');
158
+ noSecretInSupervisorFile(argv, {});
159
+ noMarkerArgument(argv);
160
+ // A task's Exec action carries no environment, so the marker is an argument (DEC-0057), before any `--`.
161
+ const cut = argv.includes('--') ? argv.indexOf('--') : argv.length;
162
+ argv.splice(cut, 0, SUPERVISOR_ARG, 'task-scheduler');
163
+ const who = user ?? (process.env.USERNAME ? `${process.env.USERDOMAIN ?? ''}\\${process.env.USERNAME}` : '');
164
+ if (!who) throw new ServiceError('Name the user the task runs as (DOMAIN\\user); USERNAME is not set.');
165
+ plain(who, 'The user');
166
+ return `<?xml version="1.0" encoding="UTF-16"?>
167
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
168
+ <RegistrationInfo><Description>${xml(description ?? task)}</Description><URI>${xml(task)}</URI></RegistrationInfo>
169
+ <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>${xml(who)}</UserId></LogonTrigger></Triggers>
170
+ <Principals><Principal id="Author"><UserId>${xml(who)}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
171
+ <Settings>
172
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
173
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
174
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
175
+ <StartWhenAvailable>true</StartWhenAvailable>
176
+ <Enabled>true</Enabled>
177
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
178
+ <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
179
+ </Settings>
180
+ <Actions Context="Author"><Exec><Command>${xml(argv[0])}</Command><Arguments>${xml(windowsCommandLine(argv.slice(1)))}</Arguments><WorkingDirectory>${xml(workingDirectory)}</WorkingDirectory></Exec></Actions>
181
+ </Task>
182
+ `;
183
+ }
184
+
185
+ const TASK_DISABLED = /<Settings>(?:(?!<\/Settings>)[\s\S])*<Enabled>false<\/Enabled>/;
186
+
187
+ /**
188
+ * Create (or replace) the task from its XML without administrator rights, then (re)start it so the new definition
189
+ * is what runs. A task already installed and disabled stays off (LC-14) unless `forceEnable`.
190
+ */
191
+ export function taskInstall(task, xmlText, { run = defaultRun, platform = process.platform, forceEnable = false } = {}) {
192
+ if (platform !== 'win32') throw new ServiceError('Task Scheduler is the supervisor on Windows only.');
193
+ const query = run(['schtasks', '/Query', '/TN', task, '/XML']);
194
+ const existed = query?.status === 0;
195
+ const keepOff = existed && !forceEnable && TASK_DISABLED.test(String(query?.stdout ?? ''));
196
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-task-'));
197
+ const file = path.join(dir, 'task.xml');
198
+ try {
199
+ // schtasks reads the encoding the declaration names: UTF-16 little-endian with its byte order mark.
200
+ fs.writeFileSync(file, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(xmlText, 'utf16le')]));
201
+ checkRun(run(['schtasks', '/Create', '/TN', task, '/XML', file, '/F']), 'schtasks /Create');
202
+ } finally {
203
+ fs.rmSync(dir, { recursive: true, force: true });
204
+ }
205
+ if (keepOff) {
206
+ checkRun(run(['schtasks', '/Change', '/TN', task, '/DISABLE']), 'schtasks /Change /DISABLE');
207
+ return;
208
+ }
209
+ if (existed) run(['schtasks', '/End', '/TN', task]);
210
+ checkRun(run(['schtasks', '/Run', '/TN', task]), 'schtasks /Run');
211
+ }
212
+
213
+ /** End the running task, then delete it. */
214
+ export function taskUninstall(task, { run = defaultRun, platform = process.platform } = {}) {
215
+ if (platform !== 'win32') throw new ServiceError('Task Scheduler is the supervisor on Windows only.');
216
+ run(['schtasks', '/End', '/TN', task]);
217
+ checkRun(run(['schtasks', '/Delete', '/TN', task, '/F']), 'schtasks /Delete');
218
+ }
219
+
220
+ // The twins of fabric_service.launchd_plist / launchd_loaded / launchd_override / launchd_install / launchd_uninstall.
221
+ // `run(argv)` returns spawnSync's {status, stdout, stderr}; the tests pass a launchd stand-in, never the real launchctl.
222
+ const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,250}$/;
223
+
224
+ const DEFAULT_PATH = '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin';
225
+
226
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
227
+
228
+ function launchdLabel(label) {
229
+ if (!LABEL.test(String(label))) throw new ServiceError(`label must match ${LABEL.source}`);
230
+ return String(label);
231
+ }
232
+
233
+ function plistValue(value, depth) {
234
+ // plistlib.dumps's layout, byte for byte: one tab per level, keys sorted, & < > escaped, empty containers self-closed.
235
+ const pad = '\t'.repeat(depth);
236
+ if (value === true || value === false) return `${pad}<${value}/>\n`;
237
+ if (Number.isInteger(value)) return `${pad}<integer>${value}</integer>\n`;
238
+ if (typeof value === 'string') return `${pad}<string>${plain(value, 'A plist string').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</string>\n`;
239
+ if (Array.isArray(value)) return value.length ? `${pad}<array>\n${value.map((v) => plistValue(v, depth + 1)).join('')}${pad}</array>\n` : `${pad}<array/>\n`;
240
+ const keys = Object.keys(value).sort();
241
+ if (!keys.length) return `${pad}<dict/>\n`;
242
+ const inner = keys.map((k) => `${pad}\t<key>${plain(k, 'A plist key').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</key>\n${plistValue(value[k], depth + 1)}`).join('');
243
+ return `${pad}<dict>\n${inner}${pad}</dict>\n`;
244
+ }
245
+
246
+ /**
247
+ * The launchd job: RunAtLoad + KeepAlive true + ThrottleInterval 10, ProcessType Standard, ExitTimeOut above the drain.
248
+ * FABRIC_SERVICE_SUPERVISOR=launchd tells the lock to back off instead of exiting into a respawn loop (DEC-0057).
249
+ * No secret is written: an environment name ending TOKEN/SECRET/PASSWORD/KEY (a `*_FILE` path is fine), a value or an
250
+ * argument shaped like a credential is refused. Returns the XML text, the same bytes Python's plistlib writes.
251
+ */
252
+ export function launchdPlist(label, programArguments, { workingDirectory, stdoutPath, environment = {}, exitTimeout = DEFAULT_EXIT_TIMEOUT } = {}) {
253
+ launchdLabel(label);
254
+ const argv = programArguments.map(String);
255
+ if (!argv.length) throw new ServiceError('A launchd job starts a program; ProgramArguments is empty.');
256
+ if (!(exitTimeout > DRAIN_SECONDS)) throw new ServiceError(`ExitTimeOut must outlast the ${DRAIN_SECONDS} s drain.`);
257
+ if (workingDirectory === undefined || stdoutPath === undefined) throw new ServiceError('A launchd job names its working directory and its log file.');
258
+ const env = { ...environment };
259
+ for (const [name, value] of Object.entries(env)) {
260
+ if (/(TOKEN|SECRET|PASSWORD|KEY)$/.test(name) && !name.endsWith('_FILE')) throw new ServiceError(`Environment variable ${name} looks like a secret; pass a *_FILE path instead.`);
261
+ if (secretValue(String(value))) throw new ServiceError(`Environment ${name} holds a value shaped like a credential; a plist never carries one.`);
262
+ }
263
+ for (const arg of argv) {
264
+ if (secretArgument(arg)) throw new ServiceError('An argument carries a credential; a plist never does (read it from the token file or a secret reference).');
265
+ }
266
+ noMarkerArgument(argv);
267
+ if (env.PATH === undefined) env.PATH = DEFAULT_PATH;
268
+ env[SUPERVISOR_ENV] = 'launchd'; // the plist's writer writes the marker, whatever the caller passed
269
+ const job = {
270
+ Label: label,
271
+ ProgramArguments: argv,
272
+ WorkingDirectory: String(workingDirectory),
273
+ RunAtLoad: true,
274
+ KeepAlive: true,
275
+ ThrottleInterval: 10,
276
+ ExitTimeOut: Math.trunc(exitTimeout),
277
+ // Standard, never Background: macOS starves a Background job under load and every host then reports an outage.
278
+ ProcessType: 'Standard',
279
+ StandardOutPath: String(stdoutPath),
280
+ StandardErrorPath: String(stdoutPath),
281
+ EnvironmentVariables: env,
282
+ };
283
+ return '<?xml version="1.0" encoding="UTF-8"?>\n'
284
+ + '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
285
+ + `<plist version="1.0">\n${plistValue(job, 0)}</plist>\n`;
286
+ }
287
+
288
+ function launchdDomain(platform, uid) {
289
+ // DEC-0032: launchd is macOS's supervisor; Linux has systemd and Windows Task Scheduler.
290
+ if (platform !== 'darwin') throw new ServiceError('launchd is the supervisor on macOS only; use systemdInstall on Linux and taskInstall on Windows.');
291
+ return `gui/${uid ?? process.getuid()}`;
292
+ }
293
+
294
+ /** True when launchd has the job loaded (`launchctl print gui/<uid>/<label>` answers). */
295
+ export function launchdLoaded(label, { run = defaultRun, platform = process.platform, uid } = {}) {
296
+ return run(['launchctl', 'print', `${launchdDomain(platform, uid)}/${launchdLabel(label)}`])?.status === 0;
297
+ }
298
+
299
+ const OVERRIDE_ROW = /^\s*"([^"]+)"\s*=>\s*(\S+)\s*$/;
300
+
301
+ /**
302
+ * The operator's launchd override for `label`: 'disabled', 'enabled', null when none is recorded (a first install), or
303
+ * 'unknown' when the table cannot be read. macOS 14+ prints `=> disabled|enabled`, older releases `=> true|false`.
304
+ */
305
+ export function launchdOverride(label, { run = defaultRun, platform = process.platform, uid } = {}) {
306
+ const result = run(['launchctl', 'print-disabled', launchdDomain(platform, uid)]);
307
+ if (result?.status !== 0) return 'unknown';
308
+ for (const line of String(result.stdout ?? '').split('\n')) {
309
+ const match = OVERRIDE_ROW.exec(line);
310
+ if (match && match[1] === label) return ['disabled', 'true'].includes(match[2]) ? 'disabled' : 'enabled';
311
+ }
312
+ return null;
313
+ }
314
+
315
+ async function wellKnownOf(origin) {
316
+ try {
317
+ const response = await fetch(`${origin}/.well-known/fabric-service`, { signal: AbortSignal.timeout(2000) });
318
+ return response.ok ? await response.json() : null;
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Write and lint the plist, bootout and WAIT for the unload, bootstrap (retrying launchd's EIO 5 times), then poll the
326
+ * well-known document until it answers with this identity; resolves to that document.
327
+ *
328
+ * The operator's intent wins (LC-14): only a first install (no override recorded) enables the label. A label the
329
+ * operator disabled stays disabled and unloaded — the plist is still rewritten, so the new release is what starts once
330
+ * they turn it back on — and the answer is {disabled: true, label, plist, loaded}. `forceEnable` is for an operator
331
+ * who asked for the service to be switched on.
332
+ */
333
+ export async function launchdInstall(label, plistPath, plistText, { origin, serviceId, instance = 'default', timeout = 40, forceEnable = false,
334
+ run = defaultRun, platform = process.platform, uid, fetchWellKnown = wellKnownOf, sleep = pause } = {}) {
335
+ const domain = launchdDomain(platform, uid);
336
+ launchdLabel(label);
337
+ if (!origin || !serviceId) throw new ServiceError('launchdInstall needs the origin and the service id it waits to see answer.');
338
+ const opts = { run, platform, uid };
339
+ atomicWrite(plistPath, plistText, 0o644);
340
+ const lint = run(['plutil', '-lint', String(plistPath)]);
341
+ if (lint?.status !== 0) throw new ServiceError(`plist failed plutil -lint: ${String(lint?.stdout || lint?.stderr || '').trim().slice(0, 200)}`);
342
+ const target = `${domain}/${label}`;
343
+ const override = launchdOverride(label, opts);
344
+ if (forceEnable || override === null) run(['launchctl', 'enable', target]);
345
+ else if (override === 'disabled') return { disabled: true, label, plist: String(plistPath), loaded: launchdLoaded(label, opts) };
346
+ run(['launchctl', 'bootout', target]);
347
+ const unloaded = Date.now() + 15_000;
348
+ while (launchdLoaded(label, opts) && Date.now() < unloaded) await sleep(250);
349
+ let last = '';
350
+ for (let attempt = 0; ; attempt += 1) {
351
+ const result = run(['launchctl', 'bootstrap', domain, String(plistPath)]);
352
+ if (result?.status === 0) break;
353
+ last = String(result?.stderr || result?.stdout || '').trim();
354
+ if (attempt === 4) throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}`);
355
+ await sleep(1000);
356
+ }
357
+ const deadline = Date.now() + timeout * 1000;
358
+ while (Date.now() < deadline) {
359
+ const doc = await fetchWellKnown(origin);
360
+ if (doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
361
+ await sleep(500);
362
+ }
363
+ throw new ServiceError(`${origin} did not answer as ${serviceId}.${instance} within ${timeout} s; read its log.`);
364
+ }
365
+
366
+ /**
367
+ * Symmetric with launchdInstall (LC-14): bootout and WAIT until the job is gone, remove the plist, and reset the
368
+ * override to enabled — launchctl has no verb that deletes an override, and a leftover `disabled` would keep a later
369
+ * fresh install off. Data stays unless `purge` (then `serviceId` names whose data, logs and cache go). A job still
370
+ * loaded at `timeout` seconds throws, leaving the plist and the data in place.
371
+ */
372
+ export async function launchdUninstall(label, plistPath, { timeout = 30, purge = false, serviceId, run = defaultRun, platform = process.platform, uid, sleep = pause } = {}) {
373
+ const domain = launchdDomain(platform, uid);
374
+ launchdLabel(label);
375
+ if (purge && !serviceId) throw new ServiceError('purge needs the service id whose data, logs and cache to remove.');
376
+ const dirs = purge ? serviceDirs(serviceId, platform) : {};
377
+ const opts = { run, platform, uid };
378
+ const target = `${domain}/${label}`;
379
+ run(['launchctl', 'bootout', target]);
380
+ const deadline = Date.now() + timeout * 1000;
381
+ while (launchdLoaded(label, opts)) {
382
+ if (Date.now() >= deadline) throw new ServiceError(`launchd job ${label} is still loaded ${timeout} s after bootout; nothing was removed.`);
383
+ await sleep(100);
384
+ }
385
+ let plistRemoved = true;
386
+ try {
387
+ fs.unlinkSync(plistPath);
388
+ } catch (error) {
389
+ if (error.code !== 'ENOENT') throw error;
390
+ plistRemoved = false;
391
+ }
392
+ run(['launchctl', 'enable', target]);
393
+ const purged = [];
394
+ for (const dir of Object.values(dirs)) {
395
+ try { fs.lstatSync(dir); } catch { continue; }
396
+ fs.rmSync(dir, { recursive: true, force: true }); // a symlinked folder: the link goes, never what it points at
397
+ purged.push(dir);
398
+ }
399
+ return { unloaded: true, plistRemoved, override: 'enabled', purged };
400
+ }
@@ -0,0 +1,46 @@
1
+ // Dates and times the way the contract reads them, once for the whole Node kit (FAA-65, layer L0); the twin of
2
+ // fabric_time.py. NaN, never an exception, answers what cannot be read: callers compare it and get a finding.
3
+ // Node 20+, no dependency; nothing here imports another kit module.
4
+
5
+ export const DAY_MS = 86_400_000;
6
+ const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
7
+
8
+ /** An RFC 3339 date-time as milliseconds, or NaN: `T`, an offset and a day that exists; a space and `:60` refused (ER-10). */
9
+ export function parseRfc3339Ms(value) {
10
+ const match = typeof value === 'string' ? RFC3339.exec(value) : null;
11
+ if (!match) return Number.NaN;
12
+ const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
13
+ const date = new Date(0);
14
+ date.setUTCFullYear(year, month - 1, day);
15
+ if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day || hour > 23 || minute > 59 || second > 59) return Number.NaN;
16
+ return Date.parse(value);
17
+ }
18
+
19
+ /** A verifier's clock as milliseconds: a Date, or epoch seconds; NaN for anything else. */
20
+ export const clockMs = (at) => (at instanceof Date ? at.getTime() : (typeof at === 'number' && Number.isFinite(at) ? at * 1000 : Number.NaN));
21
+
22
+ /** A `YYYY-MM-DD` date that exists as midnight UTC in milliseconds; NaN for anything else (the contract's `utcDate`). */
23
+ export function utcDateMs(value) {
24
+ const m = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null;
25
+ if (!m) return Number.NaN;
26
+ const [y, mo, d] = m.slice(1).map(Number);
27
+ const ms = Date.UTC(y, mo - 1, d);
28
+ const date = new Date(ms);
29
+ return date.getUTCFullYear() === y && date.getUTCMonth() === mo - 1 && date.getUTCDate() === d ? ms : Number.NaN;
30
+ }
31
+
32
+ /** The UTC calendar day (`YYYY-MM-DD`) of an instant in milliseconds. Throws a RangeError for NaN or Infinity. */
33
+ export const utcDay = (ms) => new Date(ms).toISOString().slice(0, 10);
34
+
35
+ /** An instant as RFC 3339 in UTC with milliseconds, which parseRfc3339Ms reads back to the same value. */
36
+ export const formatRfc3339 = (ms) => new Date(ms).toISOString();
37
+
38
+ /** The last `days` UTC days ending with the day of `todayMs`, oldest first. */
39
+ export function dayWindow(todayMs, days) {
40
+ if (!(days >= 1)) return [];
41
+ const start = Math.floor(todayMs / DAY_MS) * DAY_MS - (days - 1) * DAY_MS;
42
+ return Array.from({ length: days }, (_, i) => utcDay(start + i * DAY_MS));
43
+ }
44
+
45
+ /** Milliseconds since the epoch: from `clock` (milliseconds, as Date.now) when given, so a test fixes the time. */
46
+ export const nowMs = (clock = Date.now) => Number(clock());