zedx 0.4.0 → 0.6.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 +8 -1
- package/dist/check.js +0 -4
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +183 -0
- package/dist/index.js +32 -0
- package/dist/sync.d.ts +5 -0
- package/dist/sync.js +393 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/zed-paths.d.ts +2 -0
- package/dist/zed-paths.js +30 -0
- package/package.json +55 -52
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# zedx
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Scaffold [Zed Editor](https://zed.dev/) extensions and sync your settings across machines.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
@@ -30,6 +30,13 @@ zedx check
|
|
|
30
30
|
zedx version patch # 1.2.3 → 1.2.4
|
|
31
31
|
zedx version minor # 1.2.3 → 1.3.0
|
|
32
32
|
zedx version major # 1.2.3 → 2.0.0
|
|
33
|
+
|
|
34
|
+
# Sync Zed settings and extensions via a GitHub repo
|
|
35
|
+
zedx sync init # Link a GitHub repo as the sync target (run once)
|
|
36
|
+
zedx sync # Sync local and remote config automatically
|
|
37
|
+
zedx sync status # Show sync state between local config and the remote repo
|
|
38
|
+
zedx sync install # Install an OS daemon to auto-sync when Zed config changes
|
|
39
|
+
zedx sync uninstall # Remove the OS daemon
|
|
33
40
|
```
|
|
34
41
|
|
|
35
42
|
### Supported extension types:
|
package/dist/check.js
CHANGED
|
@@ -10,10 +10,6 @@ function tomlGet(content, key) {
|
|
|
10
10
|
function tomlHasUncommentedKey(content, key) {
|
|
11
11
|
return new RegExp(`^${key}\\s*=`, 'm').test(content);
|
|
12
12
|
}
|
|
13
|
-
function tomlHasSection(content, section) {
|
|
14
|
-
// Looks for an uncommented [section] or [section.something] header
|
|
15
|
-
return new RegExp(`^\\[${section.replace('.', '\\.')}`, 'm').test(content);
|
|
16
|
-
}
|
|
17
13
|
export async function runCheck(callerDir) {
|
|
18
14
|
p.intro(`${color.bgBlue(color.bold(' zedx check '))} ${color.blue('Validating extension config…')}`);
|
|
19
15
|
const tomlPath = path.join(callerDir, 'extension.toml');
|
package/dist/daemon.d.ts
ADDED
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import * as p from '@clack/prompts';
|
|
6
|
+
import color from 'picocolors';
|
|
7
|
+
import { resolveZedPaths } from './zed-paths.js';
|
|
8
|
+
const LAUNCHD_LABEL = 'dev.zedx.sync';
|
|
9
|
+
const LAUNCHD_PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
|
|
10
|
+
const SYSTEMD_SERVICE_NAME = 'zedx-sync';
|
|
11
|
+
const SYSTEMD_UNIT_DIR = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
12
|
+
const SYSTEMD_SERVICE_PATH = path.join(SYSTEMD_UNIT_DIR, `${SYSTEMD_SERVICE_NAME}.service`);
|
|
13
|
+
const SYSTEMD_PATH_PATH = path.join(SYSTEMD_UNIT_DIR, `${SYSTEMD_SERVICE_NAME}.path`);
|
|
14
|
+
function resolveZedxBinary() {
|
|
15
|
+
try {
|
|
16
|
+
const bin = execSync('which zedx', { encoding: 'utf-8' }).trim();
|
|
17
|
+
if (bin)
|
|
18
|
+
return bin;
|
|
19
|
+
}
|
|
20
|
+
catch { /* fall through */ }
|
|
21
|
+
return `${process.execPath} ${process.argv[1]}`;
|
|
22
|
+
}
|
|
23
|
+
function unsupportedPlatform() {
|
|
24
|
+
p.log.error(color.red(`zedx sync install is only supported on macOS and Linux.`));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
function buildPlist(zedxBin, watchPaths) {
|
|
28
|
+
const watchEntries = watchPaths
|
|
29
|
+
.map((wp) => ` <string>${wp}</string>`)
|
|
30
|
+
.join('\n');
|
|
31
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
32
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
33
|
+
<plist version="1.0">
|
|
34
|
+
<dict>
|
|
35
|
+
<key>Label</key>
|
|
36
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
37
|
+
|
|
38
|
+
<key>ProgramArguments</key>
|
|
39
|
+
<array>
|
|
40
|
+
<string>${zedxBin}</string>
|
|
41
|
+
<string>sync</string>
|
|
42
|
+
</array>
|
|
43
|
+
|
|
44
|
+
<key>RunAtLoad</key>
|
|
45
|
+
<true/>
|
|
46
|
+
|
|
47
|
+
<key>WatchPaths</key>
|
|
48
|
+
<array>
|
|
49
|
+
${watchEntries}
|
|
50
|
+
</array>
|
|
51
|
+
|
|
52
|
+
<key>ThrottleInterval</key>
|
|
53
|
+
<integer>30</integer>
|
|
54
|
+
|
|
55
|
+
<key>StandardOutPath</key>
|
|
56
|
+
<string>${os.homedir()}/Library/Logs/zedx-sync.log</string>
|
|
57
|
+
|
|
58
|
+
<key>StandardErrorPath</key>
|
|
59
|
+
<string>${os.homedir()}/Library/Logs/zedx-sync.log</string>
|
|
60
|
+
</dict>
|
|
61
|
+
</plist>
|
|
62
|
+
`;
|
|
63
|
+
}
|
|
64
|
+
async function installMacos(zedxBin, watchPaths) {
|
|
65
|
+
const plist = buildPlist(zedxBin, watchPaths);
|
|
66
|
+
await fs.ensureDir(path.dirname(LAUNCHD_PLIST_PATH));
|
|
67
|
+
await fs.writeFile(LAUNCHD_PLIST_PATH, plist, 'utf-8');
|
|
68
|
+
try {
|
|
69
|
+
execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'pipe' });
|
|
70
|
+
}
|
|
71
|
+
catch { /* not loaded yet */ }
|
|
72
|
+
execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`);
|
|
73
|
+
p.log.success(`Daemon installed: ${color.dim(LAUNCHD_PLIST_PATH)}`);
|
|
74
|
+
p.log.info(`Logs: ${color.dim(`${os.homedir()}/Library/Logs/zedx-sync.log`)}`);
|
|
75
|
+
p.log.info(`To check status: ${color.cyan(`launchctl list ${LAUNCHD_LABEL}`)}`);
|
|
76
|
+
}
|
|
77
|
+
async function uninstallMacos() {
|
|
78
|
+
if (!(await fs.pathExists(LAUNCHD_PLIST_PATH))) {
|
|
79
|
+
p.log.warn(color.yellow('No launchd agent found — nothing to uninstall.'));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}"`, { stdio: 'pipe' });
|
|
84
|
+
}
|
|
85
|
+
catch { /* already unloaded */ }
|
|
86
|
+
await fs.remove(LAUNCHD_PLIST_PATH);
|
|
87
|
+
p.log.success('Daemon uninstalled.');
|
|
88
|
+
}
|
|
89
|
+
function buildSystemdService(zedxBin) {
|
|
90
|
+
return `[Unit]
|
|
91
|
+
Description=zedx Zed config sync
|
|
92
|
+
After=network-online.target
|
|
93
|
+
Wants=network-online.target
|
|
94
|
+
|
|
95
|
+
[Service]
|
|
96
|
+
Type=oneshot
|
|
97
|
+
ExecStart=${zedxBin} sync
|
|
98
|
+
StandardOutput=journal
|
|
99
|
+
StandardError=journal
|
|
100
|
+
|
|
101
|
+
[Install]
|
|
102
|
+
WantedBy=default.target
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
function buildSystemdPath(watchPaths) {
|
|
106
|
+
const pathChangedEntries = watchPaths
|
|
107
|
+
.map((wp) => `PathChanged=${wp}`)
|
|
108
|
+
.join('\n');
|
|
109
|
+
return `[Unit]
|
|
110
|
+
Description=Watch Zed config files for zedx sync
|
|
111
|
+
|
|
112
|
+
[Path]
|
|
113
|
+
${pathChangedEntries}
|
|
114
|
+
Unit=${SYSTEMD_SERVICE_NAME}.service
|
|
115
|
+
|
|
116
|
+
[Install]
|
|
117
|
+
WantedBy=default.target
|
|
118
|
+
`;
|
|
119
|
+
}
|
|
120
|
+
async function installLinux(zedxBin, watchPaths) {
|
|
121
|
+
await fs.ensureDir(SYSTEMD_UNIT_DIR);
|
|
122
|
+
await fs.writeFile(SYSTEMD_SERVICE_PATH, buildSystemdService(zedxBin), 'utf-8');
|
|
123
|
+
await fs.writeFile(SYSTEMD_PATH_PATH, buildSystemdPath(watchPaths), 'utf-8');
|
|
124
|
+
execSync('systemctl --user daemon-reload');
|
|
125
|
+
execSync(`systemctl --user enable --now ${SYSTEMD_SERVICE_NAME}.path`);
|
|
126
|
+
p.log.success(`Service installed: ${color.dim(SYSTEMD_SERVICE_PATH)}`);
|
|
127
|
+
p.log.success(`Path unit installed: ${color.dim(SYSTEMD_PATH_PATH)}`);
|
|
128
|
+
p.log.info(`To check status: ${color.cyan(`systemctl --user status ${SYSTEMD_SERVICE_NAME}.path`)}`);
|
|
129
|
+
p.log.info(`Logs: ${color.cyan(`journalctl --user -u ${SYSTEMD_SERVICE_NAME}.service`)}`);
|
|
130
|
+
}
|
|
131
|
+
async function uninstallLinux() {
|
|
132
|
+
const serviceExists = await fs.pathExists(SYSTEMD_SERVICE_PATH);
|
|
133
|
+
const pathExists = await fs.pathExists(SYSTEMD_PATH_PATH);
|
|
134
|
+
if (!serviceExists && !pathExists) {
|
|
135
|
+
p.log.warn(color.yellow('No systemd units found — nothing to uninstall.'));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
execSync(`systemctl --user disable --now ${SYSTEMD_SERVICE_NAME}.path`, { stdio: 'pipe' });
|
|
140
|
+
}
|
|
141
|
+
catch { /* already inactive */ }
|
|
142
|
+
if (serviceExists)
|
|
143
|
+
await fs.remove(SYSTEMD_SERVICE_PATH);
|
|
144
|
+
if (pathExists)
|
|
145
|
+
await fs.remove(SYSTEMD_PATH_PATH);
|
|
146
|
+
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
|
|
147
|
+
p.log.success('Daemon uninstalled.');
|
|
148
|
+
}
|
|
149
|
+
export async function syncInstall() {
|
|
150
|
+
p.intro(color.bold('zedx sync install'));
|
|
151
|
+
const platform = process.platform;
|
|
152
|
+
if (platform !== 'darwin' && platform !== 'linux')
|
|
153
|
+
unsupportedPlatform();
|
|
154
|
+
const zedPaths = resolveZedPaths();
|
|
155
|
+
const watchPaths = [zedPaths.settings, zedPaths.extensions];
|
|
156
|
+
const zedxBin = resolveZedxBinary();
|
|
157
|
+
p.log.info(`Binary: ${color.dim(zedxBin)}`);
|
|
158
|
+
p.log.info(`Watching:`);
|
|
159
|
+
for (const wp of watchPaths) {
|
|
160
|
+
p.log.info(` ${color.dim(wp)}`);
|
|
161
|
+
}
|
|
162
|
+
if (platform === 'darwin') {
|
|
163
|
+
await installMacos(zedxBin, watchPaths);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
await installLinux(zedxBin, watchPaths);
|
|
167
|
+
}
|
|
168
|
+
p.outro(`${color.green('✓')} zedx sync will now run automatically whenever your Zed config changes.\n\n` +
|
|
169
|
+
` Run ${color.cyan('zedx sync uninstall')} to remove the daemon at any time.`);
|
|
170
|
+
}
|
|
171
|
+
export async function syncUninstall() {
|
|
172
|
+
p.intro(color.bold('zedx sync uninstall'));
|
|
173
|
+
const platform = process.platform;
|
|
174
|
+
if (platform !== 'darwin' && platform !== 'linux')
|
|
175
|
+
unsupportedPlatform();
|
|
176
|
+
if (platform === 'darwin') {
|
|
177
|
+
await uninstallMacos();
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
await uninstallLinux();
|
|
181
|
+
}
|
|
182
|
+
p.outro(`${color.green('✓')} Done.`);
|
|
183
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,8 @@ import { promptUser, promptThemeDetails, promptLanguageDetails } from './prompts
|
|
|
8
8
|
import { generateExtension } from './generator.js';
|
|
9
9
|
import { runCheck } from './check.js';
|
|
10
10
|
import { addTheme, addLanguage } from './add.js';
|
|
11
|
+
import { syncInit, runSync, syncStatus } from './sync.js';
|
|
12
|
+
import { syncInstall, syncUninstall } from './daemon.js';
|
|
11
13
|
function bumpVersion(version, type) {
|
|
12
14
|
const [major, minor, patch] = version.split('.').map(Number);
|
|
13
15
|
switch (type) {
|
|
@@ -76,6 +78,36 @@ async function main() {
|
|
|
76
78
|
.action(async (id) => {
|
|
77
79
|
await addLanguage(getCallerDir(), id);
|
|
78
80
|
});
|
|
81
|
+
const syncCmd = program
|
|
82
|
+
.command('sync')
|
|
83
|
+
.description('Sync Zed settings and extensions via a GitHub repo')
|
|
84
|
+
.action(async () => {
|
|
85
|
+
await runSync();
|
|
86
|
+
});
|
|
87
|
+
syncCmd
|
|
88
|
+
.command('init')
|
|
89
|
+
.description('Link a GitHub repo as the sync target')
|
|
90
|
+
.action(async () => {
|
|
91
|
+
await syncInit();
|
|
92
|
+
});
|
|
93
|
+
syncCmd
|
|
94
|
+
.command('status')
|
|
95
|
+
.description('Show sync state between local Zed config and the remote repo')
|
|
96
|
+
.action(async () => {
|
|
97
|
+
await syncStatus();
|
|
98
|
+
});
|
|
99
|
+
syncCmd
|
|
100
|
+
.command('install')
|
|
101
|
+
.description('Install the OS daemon to auto-sync when Zed config changes')
|
|
102
|
+
.action(async () => {
|
|
103
|
+
await syncInstall();
|
|
104
|
+
});
|
|
105
|
+
syncCmd
|
|
106
|
+
.command('uninstall')
|
|
107
|
+
.description('Remove the OS daemon')
|
|
108
|
+
.action(async () => {
|
|
109
|
+
await syncUninstall();
|
|
110
|
+
});
|
|
79
111
|
if (process.argv.length <= 2) {
|
|
80
112
|
const options = await promptUser();
|
|
81
113
|
if (options.types.includes('theme')) {
|
package/dist/sync.d.ts
ADDED
package/dist/sync.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import * as p from '@clack/prompts';
|
|
5
|
+
import color from 'picocolors';
|
|
6
|
+
import simpleGit from 'simple-git';
|
|
7
|
+
import { resolveZedPaths } from './zed-paths.js';
|
|
8
|
+
const ZEDX_CONFIG_DIR = path.join(os.homedir(), '.config', 'zedx');
|
|
9
|
+
const ZEDX_CONFIG_PATH = path.join(ZEDX_CONFIG_DIR, 'config.json');
|
|
10
|
+
async function readSyncConfig() {
|
|
11
|
+
if (!(await fs.pathExists(ZEDX_CONFIG_PATH))) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
return fs.readJson(ZEDX_CONFIG_PATH);
|
|
15
|
+
}
|
|
16
|
+
async function requireSyncConfig() {
|
|
17
|
+
const config = await readSyncConfig();
|
|
18
|
+
if (!config) {
|
|
19
|
+
p.log.error(color.red('No sync config found. Run ') + color.cyan('zedx sync init') + color.red(' first.'));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
return config;
|
|
23
|
+
}
|
|
24
|
+
async function writeSyncConfig(config) {
|
|
25
|
+
await fs.ensureDir(ZEDX_CONFIG_DIR);
|
|
26
|
+
await fs.writeJson(ZEDX_CONFIG_PATH, config, { spaces: 4 });
|
|
27
|
+
}
|
|
28
|
+
// Temp dir helper
|
|
29
|
+
async function withTempDir(fn) {
|
|
30
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'zedx-sync-'));
|
|
31
|
+
try {
|
|
32
|
+
return await fn(tmp);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await fs.remove(tmp);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Extension merge helper
|
|
39
|
+
async function applyRemoteSettings(repoSettings, repoExtensions, localSettingsPath, silent = false) {
|
|
40
|
+
// Backup existing settings
|
|
41
|
+
if (await fs.pathExists(localSettingsPath)) {
|
|
42
|
+
await fs.copy(localSettingsPath, localSettingsPath + '.bak', { overwrite: true });
|
|
43
|
+
if (!silent)
|
|
44
|
+
p.log.info(`Backed up settings to ${color.dim(localSettingsPath + '.bak')}`);
|
|
45
|
+
}
|
|
46
|
+
let settingsJson = await fs.readFile(repoSettings, 'utf-8');
|
|
47
|
+
// Merge auto_install_extensions from index.json into settings
|
|
48
|
+
if (await fs.pathExists(repoExtensions)) {
|
|
49
|
+
try {
|
|
50
|
+
const indexJson = (await fs.readJson(repoExtensions));
|
|
51
|
+
const extensionIds = Object.keys(indexJson.extensions ?? {}).filter((id) => !indexJson.extensions[id]?.dev);
|
|
52
|
+
if (extensionIds.length > 0) {
|
|
53
|
+
const stripped = settingsJson.replace(/\/\/[^\n]*/g, '');
|
|
54
|
+
let settingsObj = {};
|
|
55
|
+
try {
|
|
56
|
+
settingsObj = JSON.parse(stripped);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
if (!silent)
|
|
60
|
+
p.log.warn(color.yellow('Could not parse settings.json — skipping extension merge.'));
|
|
61
|
+
}
|
|
62
|
+
const autoInstall = {};
|
|
63
|
+
for (const id of extensionIds) {
|
|
64
|
+
autoInstall[id] = true;
|
|
65
|
+
}
|
|
66
|
+
settingsObj['auto_install_extensions'] = autoInstall;
|
|
67
|
+
settingsJson = JSON.stringify(settingsObj, null, 4);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
if (!silent)
|
|
72
|
+
p.log.warn(color.yellow('Could not parse extensions/index.json — skipping extension merge.'));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
await fs.ensureDir(path.dirname(localSettingsPath));
|
|
76
|
+
await fs.writeFile(localSettingsPath, settingsJson, 'utf-8');
|
|
77
|
+
}
|
|
78
|
+
// zedx sync status
|
|
79
|
+
export async function syncStatus() {
|
|
80
|
+
p.intro(color.bold('zedx sync status'));
|
|
81
|
+
const config = await requireSyncConfig();
|
|
82
|
+
const zedPaths = resolveZedPaths();
|
|
83
|
+
p.log.info(`Repo: ${color.dim(config.syncRepo)} ${color.dim(`(${config.branch})`)}`);
|
|
84
|
+
if (config.lastSync) {
|
|
85
|
+
p.log.info(`Last sync: ${color.dim(new Date(config.lastSync).toLocaleString())}`);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
p.log.info(`Last sync: ${color.dim('never')}`);
|
|
89
|
+
}
|
|
90
|
+
const spinner = p.spinner();
|
|
91
|
+
await withTempDir(async (tmp) => {
|
|
92
|
+
spinner.start(`Fetching ${config.syncRepo}...`);
|
|
93
|
+
let remoteExists = true;
|
|
94
|
+
try {
|
|
95
|
+
const git = simpleGit(tmp);
|
|
96
|
+
await git.clone(config.syncRepo, tmp, ['--depth', '1', '--branch', config.branch]);
|
|
97
|
+
spinner.stop('Remote fetched.');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
remoteExists = false;
|
|
101
|
+
spinner.stop(color.yellow('Remote is empty or branch not found.'));
|
|
102
|
+
}
|
|
103
|
+
const files = [
|
|
104
|
+
{
|
|
105
|
+
repoPath: path.join(tmp, 'settings.json'),
|
|
106
|
+
localPath: zedPaths.settings,
|
|
107
|
+
label: 'settings.json'
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
repoPath: path.join(tmp, 'extensions', 'index.json'),
|
|
111
|
+
localPath: zedPaths.extensions,
|
|
112
|
+
label: 'extensions/index.json'
|
|
113
|
+
}
|
|
114
|
+
];
|
|
115
|
+
for (const file of files) {
|
|
116
|
+
const localExists = await fs.pathExists(file.localPath);
|
|
117
|
+
const remoteFileExists = remoteExists && (await fs.pathExists(file.repoPath));
|
|
118
|
+
if (!localExists && !remoteFileExists) {
|
|
119
|
+
p.log.warn(`${color.bold(file.label)}: not found locally or remotely`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (localExists && !remoteFileExists) {
|
|
123
|
+
p.log.warn(`${color.bold(file.label)}: ${color.green('local only')} — not pushed yet`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!localExists && remoteFileExists) {
|
|
127
|
+
p.log.warn(`${color.bold(file.label)}: ${color.cyan('remote only')} — not pulled yet`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const localContent = await fs.readFile(file.localPath, 'utf-8');
|
|
131
|
+
const remoteContent = await fs.readFile(file.repoPath, 'utf-8');
|
|
132
|
+
if (localContent === remoteContent) {
|
|
133
|
+
p.log.success(`${color.bold(file.label)}: in sync`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const localMtime = (await fs.stat(file.localPath)).mtime;
|
|
137
|
+
const remoteMtime = (await fs.stat(file.repoPath)).mtime;
|
|
138
|
+
const lastSync = config.lastSync ? new Date(config.lastSync) : null;
|
|
139
|
+
const localChanged = !lastSync || localMtime > lastSync;
|
|
140
|
+
const remoteChanged = !lastSync || remoteMtime > lastSync;
|
|
141
|
+
if (localChanged && !remoteChanged) {
|
|
142
|
+
p.log.warn(`${color.bold(file.label)}: ${color.green('local ahead')} — modified ${color.dim(localMtime.toLocaleString())}`);
|
|
143
|
+
}
|
|
144
|
+
else if (remoteChanged && !localChanged) {
|
|
145
|
+
p.log.warn(`${color.bold(file.label)}: ${color.cyan('remote ahead')} — modified ${color.dim(remoteMtime.toLocaleString())}`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
p.log.warn(`${color.bold(file.label)}: ${color.yellow('conflict')} — both changed since last sync`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
p.outro(`Run ${color.cyan('zedx sync')} to resolve.`);
|
|
153
|
+
}
|
|
154
|
+
// zedx sync init
|
|
155
|
+
export async function syncInit() {
|
|
156
|
+
p.intro(color.bold('zedx sync init'));
|
|
157
|
+
const repo = await p.text({
|
|
158
|
+
message: 'GitHub repo URL (SSH or HTTPS)',
|
|
159
|
+
placeholder: 'https://github.com/you/zed-config.git',
|
|
160
|
+
validate: (v) => {
|
|
161
|
+
if (!v.trim())
|
|
162
|
+
return 'Repo URL is required';
|
|
163
|
+
if (!v.startsWith('https://') && !v.startsWith('git@')) {
|
|
164
|
+
return 'Must be a valid HTTPS or SSH git URL';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
if (p.isCancel(repo)) {
|
|
169
|
+
p.cancel('Cancelled.');
|
|
170
|
+
process.exit(0);
|
|
171
|
+
}
|
|
172
|
+
const branch = await p.text({
|
|
173
|
+
message: 'Branch name',
|
|
174
|
+
placeholder: 'main',
|
|
175
|
+
defaultValue: 'main'
|
|
176
|
+
});
|
|
177
|
+
if (p.isCancel(branch)) {
|
|
178
|
+
p.cancel('Cancelled.');
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
181
|
+
const spinner = p.spinner();
|
|
182
|
+
spinner.start('Verifying repo is reachable...');
|
|
183
|
+
try {
|
|
184
|
+
const git = simpleGit();
|
|
185
|
+
await git.listRemote(['--heads', repo]);
|
|
186
|
+
spinner.stop('Repo verified.');
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
spinner.stop(color.yellow('Could not verify repo (may be empty or private — continuing anyway).'));
|
|
190
|
+
}
|
|
191
|
+
const config = {
|
|
192
|
+
syncRepo: repo.trim(),
|
|
193
|
+
branch: (branch || 'main').trim()
|
|
194
|
+
};
|
|
195
|
+
await writeSyncConfig(config);
|
|
196
|
+
p.outro(`${color.green('✓')} Sync config saved to ${color.cyan(ZEDX_CONFIG_PATH)}\n\n` +
|
|
197
|
+
` Run ${color.cyan('zedx sync')} to sync your Zed config.`);
|
|
198
|
+
}
|
|
199
|
+
// zedx sync
|
|
200
|
+
export async function runSync(opts = {}) {
|
|
201
|
+
const { silent = false } = opts;
|
|
202
|
+
// In silent mode (daemon/watch), route all UI through plain console.log
|
|
203
|
+
// Interactive conflict prompts fall back to "local wins".
|
|
204
|
+
const log = {
|
|
205
|
+
info: (msg) => {
|
|
206
|
+
if (!silent)
|
|
207
|
+
p.log.info(msg);
|
|
208
|
+
},
|
|
209
|
+
warn: (msg) => {
|
|
210
|
+
if (!silent)
|
|
211
|
+
p.log.warn(msg);
|
|
212
|
+
else
|
|
213
|
+
console.error(`[zedx] warn: ${msg}`);
|
|
214
|
+
},
|
|
215
|
+
success: (msg) => {
|
|
216
|
+
if (!silent)
|
|
217
|
+
p.log.success(msg);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
if (!silent)
|
|
221
|
+
p.intro(color.bold('zedx sync'));
|
|
222
|
+
const config = await requireSyncConfig();
|
|
223
|
+
const zedPaths = resolveZedPaths();
|
|
224
|
+
// Spinner shim: in silent mode just log to stderr so daemons can capture it
|
|
225
|
+
const spinner = silent
|
|
226
|
+
? {
|
|
227
|
+
start: (m) => console.error(`[zedx] ${m}`),
|
|
228
|
+
stop: (m) => console.error(`[zedx] ${m}`)
|
|
229
|
+
}
|
|
230
|
+
: p.spinner();
|
|
231
|
+
await withTempDir(async (tmp) => {
|
|
232
|
+
// 1. Clone the remote repo
|
|
233
|
+
const git = simpleGit(tmp);
|
|
234
|
+
let remoteExists = true;
|
|
235
|
+
spinner.start(`Fetching ${config.syncRepo}...`);
|
|
236
|
+
try {
|
|
237
|
+
await git.clone(config.syncRepo, tmp, ['--depth', '1', '--branch', config.branch]);
|
|
238
|
+
spinner.stop('Remote fetched.');
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
remoteExists = false;
|
|
242
|
+
spinner.stop('Remote is empty or branch not found — will push fresh.');
|
|
243
|
+
await git.init();
|
|
244
|
+
await git.addRemote('origin', config.syncRepo);
|
|
245
|
+
}
|
|
246
|
+
// 2. Determine what changed for each file
|
|
247
|
+
const lastSync = config.lastSync ? new Date(config.lastSync) : null;
|
|
248
|
+
const files = [
|
|
249
|
+
{
|
|
250
|
+
repoPath: path.join(tmp, 'settings.json'),
|
|
251
|
+
localPath: zedPaths.settings,
|
|
252
|
+
label: 'settings.json'
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
repoPath: path.join(tmp, 'extensions', 'index.json'),
|
|
256
|
+
localPath: zedPaths.extensions,
|
|
257
|
+
label: 'extensions/index.json'
|
|
258
|
+
}
|
|
259
|
+
];
|
|
260
|
+
let anyChanges = false;
|
|
261
|
+
for (const file of files) {
|
|
262
|
+
const localExists = await fs.pathExists(file.localPath);
|
|
263
|
+
const remoteFileExists = remoteExists && (await fs.pathExists(file.repoPath));
|
|
264
|
+
// Both missing — skip
|
|
265
|
+
if (!localExists && !remoteFileExists) {
|
|
266
|
+
log.warn(`${file.label}: not found locally or remotely — skipping.`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
// Remote doesn't have it yet — push local
|
|
270
|
+
if (localExists && !remoteFileExists) {
|
|
271
|
+
log.info(`${file.label}: ${color.green('pushing')} (not in remote yet)`);
|
|
272
|
+
await fs.ensureDir(path.dirname(file.repoPath));
|
|
273
|
+
await fs.copy(file.localPath, file.repoPath, { overwrite: true });
|
|
274
|
+
anyChanges = true;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
// Local doesn't have it — pull remote
|
|
278
|
+
if (!localExists && remoteFileExists) {
|
|
279
|
+
log.info(`${file.label}: ${color.cyan('pulling')} (not found locally)`);
|
|
280
|
+
if (file.label === 'settings.json') {
|
|
281
|
+
await applyRemoteSettings(file.repoPath, path.join(tmp, 'extensions', 'index.json'), file.localPath, silent);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
await fs.ensureDir(path.dirname(file.localPath));
|
|
285
|
+
await fs.copy(file.repoPath, file.localPath, { overwrite: true });
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
// Both exist — compare content
|
|
290
|
+
const localContent = await fs.readFile(file.localPath, 'utf-8');
|
|
291
|
+
const remoteContent = await fs.readFile(file.repoPath, 'utf-8');
|
|
292
|
+
if (localContent === remoteContent) {
|
|
293
|
+
log.success(`${file.label}: ${color.dim('already in sync')}`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
// Detect which side changed since last sync via mtime
|
|
297
|
+
const localMtime = (await fs.stat(file.localPath)).mtime;
|
|
298
|
+
const remoteMtime = remoteFileExists ? (await fs.stat(file.repoPath)).mtime : new Date(0);
|
|
299
|
+
const localChanged = !lastSync || localMtime > lastSync;
|
|
300
|
+
const remoteChanged = !lastSync || remoteMtime > lastSync;
|
|
301
|
+
if (localChanged && !remoteChanged) {
|
|
302
|
+
// Only local changed → push
|
|
303
|
+
log.info(`${file.label}: ${color.green('pushing')} (local is newer)`);
|
|
304
|
+
await fs.ensureDir(path.dirname(file.repoPath));
|
|
305
|
+
await fs.copy(file.localPath, file.repoPath, { overwrite: true });
|
|
306
|
+
anyChanges = true;
|
|
307
|
+
}
|
|
308
|
+
else if (remoteChanged && !localChanged) {
|
|
309
|
+
// Only remote changed → pull
|
|
310
|
+
log.info(`${file.label}: ${color.cyan('pulling')} (remote is newer)`);
|
|
311
|
+
if (file.label === 'settings.json') {
|
|
312
|
+
await applyRemoteSettings(file.repoPath, path.join(tmp, 'extensions', 'index.json'), file.localPath, silent);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
await fs.ensureDir(path.dirname(file.localPath));
|
|
316
|
+
await fs.copy(file.repoPath, file.localPath, { overwrite: true });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
// Both changed
|
|
321
|
+
if (silent) {
|
|
322
|
+
// Daemon can't prompt — local wins, will be pushed
|
|
323
|
+
log.warn(`${file.label}: conflict detected in unattended mode — keeping local.`);
|
|
324
|
+
await fs.ensureDir(path.dirname(file.repoPath));
|
|
325
|
+
await fs.copy(file.localPath, file.repoPath, { overwrite: true });
|
|
326
|
+
anyChanges = true;
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
p.log.warn(color.yellow(`${file.label}: both local and remote changed.`));
|
|
330
|
+
const choice = await p.select({
|
|
331
|
+
message: `Which version of ${color.bold(file.label)} should win?`,
|
|
332
|
+
options: [
|
|
333
|
+
{
|
|
334
|
+
value: 'local',
|
|
335
|
+
label: 'Keep local',
|
|
336
|
+
hint: `modified ${localMtime.toLocaleString()}`
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
value: 'remote',
|
|
340
|
+
label: 'Use remote',
|
|
341
|
+
hint: `modified ${remoteMtime.toLocaleString()}`
|
|
342
|
+
}
|
|
343
|
+
]
|
|
344
|
+
});
|
|
345
|
+
if (p.isCancel(choice)) {
|
|
346
|
+
p.cancel('Cancelled.');
|
|
347
|
+
process.exit(0);
|
|
348
|
+
}
|
|
349
|
+
if (choice === 'local') {
|
|
350
|
+
p.log.info(`${file.label}: ${color.green('keeping local, will push')}`);
|
|
351
|
+
await fs.ensureDir(path.dirname(file.repoPath));
|
|
352
|
+
await fs.copy(file.localPath, file.repoPath, { overwrite: true });
|
|
353
|
+
anyChanges = true;
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
p.log.info(`${file.label}: ${color.cyan('applying remote')}`);
|
|
357
|
+
if (file.label === 'settings.json') {
|
|
358
|
+
await applyRemoteSettings(file.repoPath, path.join(tmp, 'extensions', 'index.json'), file.localPath, silent);
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
await fs.ensureDir(path.dirname(file.localPath));
|
|
362
|
+
await fs.copy(file.repoPath, file.localPath, { overwrite: true });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// 3. Commit + push if any local files were written to the repo
|
|
369
|
+
if (anyChanges) {
|
|
370
|
+
spinner.start('Pushing changes to remote...');
|
|
371
|
+
await git.add(['settings.json', path.join('extensions', 'index.json')]);
|
|
372
|
+
const status = await git.status();
|
|
373
|
+
if (status.staged.length > 0) {
|
|
374
|
+
const timestamp = new Date().toISOString();
|
|
375
|
+
await git.commit(`sync: ${timestamp}`);
|
|
376
|
+
try {
|
|
377
|
+
await git.push('origin', config.branch, ['--set-upstream']);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
await git.push('origin', config.branch);
|
|
381
|
+
}
|
|
382
|
+
spinner.stop('Pushed.');
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
spinner.stop('Nothing staged to push.');
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
// 4. Save last sync timestamp
|
|
390
|
+
await writeSyncConfig({ ...config, lastSync: new Date().toISOString() });
|
|
391
|
+
if (!silent)
|
|
392
|
+
p.outro(`${color.green('✓')} Sync complete.`);
|
|
393
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
export type ExtensionType = 'theme' | 'language';
|
|
2
|
+
export interface ZedPaths {
|
|
3
|
+
settings: string;
|
|
4
|
+
extensions: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SyncConfig {
|
|
7
|
+
syncRepo: string;
|
|
8
|
+
branch: string;
|
|
9
|
+
}
|
|
2
10
|
export type License = 'Apache-2.0' | 'BSD-2-Clause' | 'BSD-3-Clause' | 'GPL-3.0' | 'LGPL-3.0' | 'MIT' | 'Zlib';
|
|
3
11
|
export interface ExtensionOptions {
|
|
4
12
|
name: string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
export function resolveZedPaths() {
|
|
4
|
+
const home = os.homedir();
|
|
5
|
+
const platform = process.platform;
|
|
6
|
+
if (platform === 'darwin') {
|
|
7
|
+
return {
|
|
8
|
+
settings: path.join(home, '.config', 'zed', 'settings.json'),
|
|
9
|
+
extensions: path.join(home, 'Library', 'Application Support', 'Zed', 'extensions', 'index.json'),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
if (platform === 'linux') {
|
|
13
|
+
const xdgData = process.env.FLATPAK_XDG_DATA_HOME ||
|
|
14
|
+
process.env.XDG_DATA_HOME ||
|
|
15
|
+
path.join(home, '.local', 'share');
|
|
16
|
+
return {
|
|
17
|
+
settings: path.join(home, '.config', 'zed', 'settings.json'),
|
|
18
|
+
extensions: path.join(xdgData, 'zed', 'extensions', 'index.json'),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (platform === 'win32') {
|
|
22
|
+
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
23
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
24
|
+
return {
|
|
25
|
+
settings: path.join(appData, 'Zed', 'settings.json'),
|
|
26
|
+
extensions: path.join(localAppData, 'Zed', 'extensions', 'index.json'),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`Unsupported platform: ${platform}`);
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,53 +1,56 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
2
|
+
"name": "zedx",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Boilerplate generator for Zed Edittor extensions.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"zedx": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"zed",
|
|
15
|
+
"zed-editor",
|
|
16
|
+
"extension",
|
|
17
|
+
"boilerplate",
|
|
18
|
+
"scaffold"
|
|
19
|
+
],
|
|
20
|
+
"author": "Taha Nejad <taha@noiserandom.com>",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/tahayvr/zedx.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/tahayvr/zedx#readme",
|
|
26
|
+
"license": "Apache-2.0",
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/ejs": "^3.1.5",
|
|
35
|
+
"@types/fs-extra": "^11.0.4",
|
|
36
|
+
"@types/node": "^25.2.3",
|
|
37
|
+
"oxlint": "^1.57.0",
|
|
38
|
+
"tsx": "^4.21.0",
|
|
39
|
+
"typescript": "^5.9.3"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@clack/prompts": "^0.10.1",
|
|
43
|
+
"commander": "^14.0.3",
|
|
44
|
+
"ejs": "^4.0.1",
|
|
45
|
+
"fs-extra": "^11.3.3",
|
|
46
|
+
"picocolors": "^1.1.1",
|
|
47
|
+
"simple-git": "^3.33.0"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc && cp -r src/templates dist/",
|
|
51
|
+
"start": "node dist/index.js",
|
|
52
|
+
"dev": "tsx src/index.ts",
|
|
53
|
+
"lint": "oxlint",
|
|
54
|
+
"lint:fix": "oxlint --fix"
|
|
55
|
+
}
|
|
56
|
+
}
|