@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.
- package/README.md +60 -0
- package/index.mjs +184 -0
- 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
|
+
}
|