@epistery/code-sync 1.0.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 (3) hide show
  1. package/README.md +60 -0
  2. package/index.mjs +184 -0
  3. package/package.json +24 -0
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @epistery/code-sync
2
+
3
+ The one place the "advance a git checkout to a branch, then act" logic lives, so
4
+ epistery services stop each rolling their own. No dependency on
5
+ `@metric-im/administrate`, and **not** part of core `epistery` (which is tight
6
+ public middleware — no ops code there).
7
+
8
+ ## Core
9
+
10
+ ```js
11
+ import { syncCheckout } from '@epistery/code-sync';
12
+
13
+ const res = await syncCheckout({
14
+ dir, // checkout root (contains .git)
15
+ branch: 'main',
16
+ advance: 'ff-only', // 'ff-only' (never clobbers) | 'reset-hard'
17
+ install: 'if-changed', // 'if-changed' (default) | 'always' | 'never'
18
+ installer: ['ci', '--no-audit', '--no-fund'], // npm args (optional)
19
+ });
20
+ // → { advanced, before, after, installed }
21
+ ```
22
+
23
+ `syncCheckout` fetches, **no-ops when already current**, else advances and
24
+ installs only when `package.json`/`package-lock.json` changed. It performs **no**
25
+ restart — the caller decides what an advance means.
26
+
27
+ ## Poll trigger (self-updating service under a supervisor)
28
+
29
+ ```js
30
+ import { poll } from '@epistery/code-sync';
31
+
32
+ const sync = poll({
33
+ dir: appRoot,
34
+ branch: 'main',
35
+ intervalMs: 60_000,
36
+ onAdvance: () => process.exit(0), // systemd Restart=always respawns on new code
37
+ });
38
+ // sync.stop() — stop polling
39
+ // sync.check() — force an immediate check (a future webhook/admin trigger reuses this)
40
+ ```
41
+
42
+ Ticks never overlap; the interval timer is `unref`'d, so polling alone won't keep
43
+ the process alive.
44
+
45
+ ## Restart model
46
+
47
+ `code-sync` never restarts the process itself. For a service that keeps its **own**
48
+ code current, run it under a supervisor (systemd `Restart=always`) and pass
49
+ `onAdvance: () => process.exit(0)` (optionally close the server first for a graceful
50
+ exit). The install runs in the old process; the restart brings up the new code with
51
+ the new deps already in place.
52
+
53
+ ## Not here yet
54
+
55
+ Signed-webhook (GitHub `X-Hub-Signature-256`) and admin-route triggers — the
56
+ harness `sync.mjs` and epistery-host `PluginManager`/`AgentManager` shapes — layer
57
+ over the same `syncCheckout` core and can migrate onto this module later. They're
58
+ omitted for now to keep this dependency-free (no express, no auth model baked in).
59
+ Credential/token injection for private managed clones is likewise a caller concern,
60
+ added as a parameter if/when those callers migrate.
package/index.mjs ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * @epistery/code-sync
3
+ *
4
+ * The one place the "advance a git checkout to a branch, then act" logic lives,
5
+ * so services stop each rolling their own (harness/sync.mjs, epistery-host's
6
+ * PluginManager/AgentManager). It deliberately does NOT depend on
7
+ * @metric-im/administrate (whose Synchronize is the very thing services copied
8
+ * to avoid that dep) and is NOT part of core `epistery` (tight public
9
+ * middleware — no ops code there).
10
+ *
11
+ * The shared CORE is `syncCheckout()`: fetch, no-op when already current, then
12
+ * advance and (only when deps changed) install. Everything that legitimately
13
+ * differs between callers is a parameter:
14
+ * - advance strategy: 'ff-only' (never clobbers, the harness's choice) vs 'reset-hard'
15
+ * - install policy: 'if-changed' (default) | 'always' | 'never', + which installer
16
+ * - what to do after an advance: the caller's onAdvance (restart, hot-reload, …)
17
+ *
18
+ * `poll()` is the first TRIGGER, for a service that keeps its OWN code current
19
+ * under a supervisor: check origin/<branch> on an interval and, when it advanced,
20
+ * hand off to onAdvance (console/relay pass `() => process.exit(0)` and let
21
+ * systemd Restart=always respawn on the new code). Signed-webhook and admin-route
22
+ * triggers (the harness / plugin-manager shapes) can layer on later over the same
23
+ * core; they're intentionally not here yet (they'd drag in express + an auth model).
24
+ *
25
+ * `attach()` is how a host turns it on: settings come from the ONE config system,
26
+ * epistery `Config` (a `[code-sync]` section in the host's own `~/.epistery`
27
+ * config), never env vars — so code-sync's config sits beside the host it runs for.
28
+ * No `[code-sync]` section (or `enabled=false`) = it does nothing.
29
+ *
30
+ * No credential/token handling: a checkout that self-updates fetches with its own
31
+ * remote (deploy key / https). The managed-clone token injection AgentManager does
32
+ * is that caller's concern, not this core's — add it as a param if/when it migrates.
33
+ */
34
+ import { spawn } from 'child_process';
35
+ import { Config } from 'epistery';
36
+
37
+ // Fail fast instead of hanging on an auth prompt when a remote needs credentials.
38
+ const HOSTILE_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' };
39
+
40
+ function run(cmd, args, cwd) {
41
+ return new Promise((resolve, reject) => {
42
+ const child = spawn(cmd, args, { cwd, env: HOSTILE_ENV, stdio: ['ignore', 'pipe', 'pipe'] });
43
+ let out = '', err = '';
44
+ child.stdout.on('data', (d) => { out += d; });
45
+ child.stderr.on('data', (d) => { err += d; });
46
+ child.on('error', reject);
47
+ child.on('close', (code) => resolve({ code, stdout: out.trim(), stderr: err.trim() }));
48
+ });
49
+ }
50
+
51
+ async function git(args, cwd) {
52
+ const r = await run('git', args, cwd);
53
+ if (r.code !== 0) throw new Error(`git ${args.join(' ')} failed (exit ${r.code}): ${r.stderr || r.stdout}`);
54
+ return r.stdout;
55
+ }
56
+
57
+ // `git diff --quiet` exits non-zero exactly when the listed paths changed between
58
+ // the two commits — so we install only when package.json / lockfile actually moved.
59
+ async function depsChanged(dir, before, after) {
60
+ const r = await run('git', ['diff', '--quiet', before, after, '--', 'package.json', 'package-lock.json'], dir);
61
+ return r.code !== 0;
62
+ }
63
+
64
+ /**
65
+ * Advance one checkout to origin/<branch>. Pure of any restart/reload — the
66
+ * caller decides what to do with the result.
67
+ *
68
+ * @param {object} opts
69
+ * @param {string} opts.dir checkout root (contains .git)
70
+ * @param {string} [opts.branch='main'] branch to track
71
+ * @param {'ff-only'|'reset-hard'} [opts.advance='ff-only']
72
+ * @param {'if-changed'|'always'|'never'} [opts.install='if-changed']
73
+ * @param {string[]} [opts.installer=['ci','--no-audit','--no-fund']] npm args
74
+ * @returns {Promise<{advanced:boolean, before:string, after:string, installed:boolean}>}
75
+ */
76
+ export async function syncCheckout({ dir, branch = 'main', advance = 'ff-only', install = 'if-changed', installer } = {}) {
77
+ if (!dir) throw new Error('code-sync: dir is required');
78
+ await git(['fetch', 'origin', branch], dir);
79
+ const before = await git(['rev-parse', 'HEAD'], dir);
80
+ const target = await git(['rev-parse', `origin/${branch}`], dir);
81
+ if (before === target) return { advanced: false, before, after: before, installed: false };
82
+
83
+ if (advance === 'ff-only') await git(['merge', '--ff-only', `origin/${branch}`], dir);
84
+ else if (advance === 'reset-hard') await git(['reset', '--hard', `origin/${branch}`], dir);
85
+ else throw new Error(`code-sync: unknown advance strategy '${advance}'`);
86
+
87
+ const after = await git(['rev-parse', 'HEAD'], dir);
88
+
89
+ const wantInstall = install === 'always' ? true
90
+ : install === 'never' ? false
91
+ : await depsChanged(dir, before, after); // 'if-changed'
92
+ if (wantInstall) {
93
+ const args = installer || ['ci', '--no-audit', '--no-fund'];
94
+ const r = await run('npm', args, dir);
95
+ if (r.code !== 0) throw new Error(`npm ${args.join(' ')} failed (exit ${r.code}): ${r.stderr || r.stdout}`);
96
+ }
97
+ return { advanced: true, before, after, installed: wantInstall };
98
+ }
99
+
100
+ /**
101
+ * Poll origin/<branch> on an interval; on an advance, call onAdvance. Ticks never
102
+ * overlap, and the timer is unref'd so polling alone won't hold the process open.
103
+ *
104
+ * @param {object} opts — dir/branch/advance/install/installer as syncCheckout, plus:
105
+ * @param {number} [opts.intervalMs=60000]
106
+ * @param {(res)=>any} [opts.onAdvance] e.g. () => process.exit(0) under a supervisor
107
+ * @param {(err)=>any} [opts.onError]
108
+ * @param {{log?:Function,warn?:Function}} [opts.logger=console]
109
+ * @returns {{stop:()=>void, check:()=>Promise<void>}}
110
+ */
111
+ export function poll({ dir, branch = 'main', intervalMs = 60_000, advance = 'ff-only', install = 'if-changed', installer, onAdvance, onError, logger = console } = {}) {
112
+ if (!dir) throw new Error('code-sync: dir is required');
113
+ let running = false;
114
+ let stopped = false;
115
+
116
+ async function check() {
117
+ if (running || stopped) return;
118
+ running = true;
119
+ try {
120
+ const res = await syncCheckout({ dir, branch, advance, install, installer });
121
+ if (res.advanced) {
122
+ logger?.log?.(`[code-sync] ${dir} advanced ${res.before.slice(0, 7)}→${res.after.slice(0, 7)}${res.installed ? ' (deps installed)' : ''}`);
123
+ if (onAdvance) await onAdvance(res);
124
+ }
125
+ } catch (e) {
126
+ if (onError) onError(e); else logger?.warn?.(`[code-sync] ${dir}: ${e.message}`);
127
+ } finally {
128
+ running = false;
129
+ }
130
+ }
131
+
132
+ const timer = setInterval(check, intervalMs);
133
+ timer.unref?.();
134
+
135
+ return {
136
+ stop() { stopped = true; clearInterval(timer); },
137
+ check, // trigger an immediate check (a future webhook/admin trigger reuses this)
138
+ };
139
+ }
140
+
141
+ const OFF = new Set(['false', 'no', '0', 'off']);
142
+
143
+ /**
144
+ * Turn code-sync on from the host's epistery Config — the ONE config system, no
145
+ * env vars. Reads a `[code-sync]` section from the root config (`~/.epistery`,
146
+ * beside the host's own settings) and, if present and not disabled, polls
147
+ * origin/<branch>. Absent section (or `enabled=false`) → returns null, does
148
+ * nothing. Never throws into the host: a config-read failure is logged and
149
+ * treated as "not configured".
150
+ *
151
+ * `[code-sync]` keys (all optional): enabled (default on when the section exists),
152
+ * branch (main), interval (seconds, 60), advance (ff-only|reset-hard),
153
+ * install (if-changed|always|never).
154
+ *
155
+ * @param {object} opts
156
+ * @param {string} opts.dir checkout root (contains .git)
157
+ * @param {string} [opts.section='code-sync']
158
+ * @param {(res)=>any} [opts.onAdvance] e.g. () => process.exit(0) under a supervisor
159
+ * @param {{log?:Function,warn?:Function}} [opts.logger=console]
160
+ * @returns {Promise<{stop:()=>void,check:()=>Promise<void>}|null>}
161
+ */
162
+ export async function attach({ dir, section = 'code-sync', onAdvance, logger = console } = {}) {
163
+ if (!dir) throw new Error('code-sync: dir is required');
164
+ let cfg;
165
+ try {
166
+ cfg = new Config();
167
+ await cfg.setPath('/'); // root ~/.epistery/config.ini (loads into cfg.data)
168
+ } catch (e) {
169
+ logger?.warn?.(`[code-sync] could not read epistery Config — not tracking: ${e.message}`);
170
+ return null;
171
+ }
172
+ const sec = cfg.data?.[section];
173
+ if (!sec) { logger?.log?.(`[code-sync] no [${section}] in epistery Config — not tracking`); return null; }
174
+ if (OFF.has(String(sec.enabled ?? '').toLowerCase())) {
175
+ logger?.log?.(`[code-sync] [${section}] enabled=false — not tracking`);
176
+ return null;
177
+ }
178
+ const branch = sec.branch || 'main';
179
+ const intervalMs = sec.interval ? Math.max(5, parseInt(sec.interval, 10)) * 1000 : 60_000;
180
+ const advance = sec.advance || 'ff-only';
181
+ const install = sec.install || 'if-changed';
182
+ logger?.log?.(`[code-sync] tracking origin/${branch} (poll ${intervalMs / 1000}s) from [${section}] config`);
183
+ return poll({ dir, branch, intervalMs, advance, install, onAdvance, logger });
184
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@epistery/code-sync",
3
+ "version": "1.0.0",
4
+ "description": "Keep an epistery service's own git checkout in sync with a branch and self-restart. Poll or trigger; ff-only or reset-hard; install only when deps change.",
5
+ "type": "module",
6
+ "main": "index.mjs",
7
+ "exports": {
8
+ ".": "./index.mjs",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "index.mjs",
13
+ "README.md"
14
+ ],
15
+ "dependencies": {
16
+ "epistery": "^2.3.0"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Rootz Corp",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/epistery/code-sync.git"
23
+ }
24
+ }