@andrestobelem/clone 0.0.0-stage → 0.1.1

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 +36 -2
  2. package/bin/clone.js +228 -0
  3. package/package.json +24 -4
package/README.md CHANGED
@@ -1,3 +1,37 @@
1
- # Temporary Holding Version
1
+ # Clone
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Run a local issue and project workspace without installing Python.
4
+
5
+ Website: <https://clone-andrestobelem-prod.web.app>
6
+
7
+ ```sh
8
+ npx @andrestobelem/clone
9
+ ```
10
+
11
+ Clone stores its database, attachments, snapshots, and backups in the operating system's user data directory. The server listens on `127.0.0.1` and opens `http://127.0.0.1:4173`.
12
+
13
+ ## Commands
14
+
15
+ ```sh
16
+ npx @andrestobelem/clone start --port 5000
17
+ npx @andrestobelem/clone --local
18
+ npx @andrestobelem/clone --data-dir ./project-data
19
+ npx @andrestobelem/clone teams list --json
20
+ npx @andrestobelem/clone --help
21
+ ```
22
+
23
+ Use `--no-open` with `start` to keep the browser closed. Set `CLONE_DATA_DIR` to select another data directory.
24
+
25
+ `--local` stores the workspace in `.clone/` in the current directory. Add `.clone/` to the repository's `.gitignore` unless you have a specific backup workflow. `--data-dir PATH` selects an explicit directory.
26
+
27
+ ## Data locations
28
+
29
+ | System | Workspace data | Downloaded executable cache |
30
+ | --- | --- | --- |
31
+ | macOS | `~/Library/Application Support/Clone/` | `~/Library/Caches/Clone/` |
32
+ | Linux | `$XDG_DATA_HOME/clone/` or `~/.local/share/clone/` | `$XDG_CACHE_HOME/clone/` or `~/.cache/clone/` |
33
+ | Windows | `%APPDATA%\Clone\` | `%LOCALAPPDATA%\Clone\cache\` |
34
+
35
+ The workspace data directory contains `workspace.sqlite3`, snapshots, attachments, and backups. Set `CLONE_CACHE_DIR` to select another executable cache.
36
+
37
+ Clone supports macOS on ARM64 and x64, Linux on ARM64 and x64, and Windows on x64.
package/bin/clone.js ADDED
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const crypto = require("node:crypto");
5
+ const fs = require("node:fs");
6
+ const os = require("node:os");
7
+ const path = require("node:path");
8
+ const { spawn, spawnSync } = require("node:child_process");
9
+ const packageMetadata = require("../package.json");
10
+
11
+ const RELEASE_ROOT = "https://github.com/andrestobelem/clone/releases/download";
12
+ const TARGETS = new Map([
13
+ ["darwin-arm64", "clone-darwin-arm64"],
14
+ ["darwin-x64", "clone-darwin-x64"],
15
+ ["linux-arm64", "clone-linux-arm64"],
16
+ ["linux-x64", "clone-linux-x64"],
17
+ ["win32-x64", "clone-win32-x64.exe"],
18
+ ]);
19
+ const BLOCKED_PORTS = new Set([
20
+ 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95,
21
+ 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179,
22
+ 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601,
23
+ 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566,
24
+ 6665, 6666, 6667, 6668, 6669, 6697, 10080,
25
+ ]);
26
+
27
+ function target(platform = process.platform, architecture = process.arch) {
28
+ const value = TARGETS.get(`${platform}-${architecture}`);
29
+ if (!value) throw new Error(`Clone does not support ${platform}/${architecture}.`);
30
+ return value;
31
+ }
32
+
33
+ function cacheDirectory(platform = process.platform, environment = process.env) {
34
+ if (environment.CLONE_CACHE_DIR) return path.resolve(environment.CLONE_CACHE_DIR);
35
+ if (platform === "darwin") return path.join(os.homedir(), "Library", "Caches", "Clone");
36
+ if (platform === "win32") return path.join(environment.LOCALAPPDATA || os.homedir(), "Clone", "cache");
37
+ return path.join(environment.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "clone");
38
+ }
39
+
40
+ function dataDirectory(platform = process.platform, environment = process.env, override = null) {
41
+ if (override) return path.resolve(override);
42
+ if (environment.CLONE_DATA_DIR) return path.resolve(environment.CLONE_DATA_DIR);
43
+ if (platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", "Clone");
44
+ if (platform === "win32") return path.join(environment.APPDATA || os.homedir(), "Clone");
45
+ return path.join(environment.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"), "clone");
46
+ }
47
+
48
+ function parseLauncherArguments(arguments_, workingDirectory = process.cwd()) {
49
+ let storage = null;
50
+ const forwarded = [];
51
+ for (let index = 0; index < arguments_.length; index += 1) {
52
+ const value = arguments_[index];
53
+ if (value === "--local") {
54
+ if (storage) throw new Error("Use only one data directory option.");
55
+ storage = path.join(workingDirectory, ".clone");
56
+ } else if (value === "--data-dir") {
57
+ if (storage) throw new Error("Use only one data directory option.");
58
+ const directory = arguments_[++index];
59
+ if (!directory) throw new Error("--data-dir requires a path.");
60
+ storage = path.resolve(workingDirectory, directory);
61
+ } else if (value.startsWith("--data-dir=")) {
62
+ if (storage) throw new Error("Use only one data directory option.");
63
+ const directory = value.slice(11);
64
+ if (!directory) throw new Error("--data-dir requires a path.");
65
+ storage = path.resolve(workingDirectory, directory);
66
+ } else {
67
+ forwarded.push(value);
68
+ }
69
+ }
70
+ return { arguments: forwarded, storage };
71
+ }
72
+
73
+ async function download(url, destination) {
74
+ const response = await fetch(url, { headers: { "user-agent": `clone/${packageMetadata.version}` } });
75
+ if (!response.ok) throw new Error(`Download failed (${response.status}): ${url}`);
76
+ const temporary = `${destination}.download`;
77
+ fs.writeFileSync(temporary, Buffer.from(await response.arrayBuffer()), { mode: 0o755 });
78
+ fs.renameSync(temporary, destination);
79
+ }
80
+
81
+ function checksum(file) {
82
+ return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
83
+ }
84
+
85
+ async function binaryPath() {
86
+ if (process.env.CLONE_BINARY_PATH) return path.resolve(process.env.CLONE_BINARY_PATH);
87
+ const filename = target();
88
+ const directory = path.join(cacheDirectory(), packageMetadata.version);
89
+ const binary = path.join(directory, filename);
90
+ const checksums = path.join(directory, "checksums.txt");
91
+ fs.mkdirSync(directory, { recursive: true });
92
+ const release = `${RELEASE_ROOT}/v${packageMetadata.version}`;
93
+ if (!fs.existsSync(checksums)) await download(`${release}/checksums.txt`, checksums);
94
+ const expectedLine = fs.readFileSync(checksums, "utf8").split(/\r?\n/).find((line) => line.endsWith(` ${filename}`));
95
+ if (!expectedLine) throw new Error(`The release has no checksum for ${filename}.`);
96
+ const expected = expectedLine.split(/\s+/)[0];
97
+ if (!fs.existsSync(binary) || checksum(binary) !== expected) {
98
+ fs.rmSync(binary, { force: true });
99
+ await download(`${release}/${filename}`, binary);
100
+ }
101
+ if (checksum(binary) !== expected) {
102
+ fs.rmSync(binary, { force: true });
103
+ throw new Error(`Checksum verification failed for ${filename}.`);
104
+ }
105
+ if (process.platform !== "win32") fs.chmodSync(binary, 0o755);
106
+ return binary;
107
+ }
108
+
109
+ function openBrowser(url) {
110
+ const command = process.platform === "darwin" ? ["open", [url]]
111
+ : process.platform === "win32" ? ["cmd", ["/d", "/s", "/c", "start", "", url]]
112
+ : ["xdg-open", [url]];
113
+ const child = spawn(command[0], command[1], { detached: true, stdio: "ignore" });
114
+ child.unref();
115
+ }
116
+
117
+ async function waitForServer(url, child, errorState) {
118
+ for (let attempt = 0; attempt < 80; attempt += 1) {
119
+ if (errorState.value) throw errorState.value;
120
+ if (child.exitCode !== null) throw new Error(`Clone stopped with exit code ${child.exitCode}.`);
121
+ try {
122
+ const response = await fetch(`${url}/api/health`);
123
+ if (response.ok) return;
124
+ } catch {}
125
+ await new Promise((resolve) => setTimeout(resolve, 250));
126
+ }
127
+ throw new Error(`Clone did not start at ${url}.`);
128
+ }
129
+
130
+ function parseStartArguments(arguments_) {
131
+ let port = process.env.PORT || "4173";
132
+ let open = true;
133
+ for (let index = 0; index < arguments_.length; index += 1) {
134
+ const value = arguments_[index];
135
+ if (value === "--no-open") open = false;
136
+ else if (value === "--port") port = arguments_[++index];
137
+ else if (value.startsWith("--port=")) port = value.slice(7);
138
+ else throw new Error(`Unknown start option: ${value}`);
139
+ }
140
+ if (!/^\d+$/.test(String(port)) || Number(port) < 1 || Number(port) > 65535) throw new Error("Port must be between 1 and 65535.");
141
+ if (BLOCKED_PORTS.has(Number(port))) throw new Error(`Port ${port} is blocked by web browsers.`);
142
+ return { port: String(port), open };
143
+ }
144
+
145
+ function initialize(binary, database, storage) {
146
+ if (fs.existsSync(database) || fs.existsSync(path.join(storage, "snapshot"))) return;
147
+ fs.mkdirSync(storage, { recursive: true });
148
+ const result = spawnSync(binary, ["--db", database, "init"], { encoding: "utf8", stdio: "inherit" });
149
+ if (result.error) throw result.error;
150
+ if (result.status !== 0) throw new Error(`Clone initialization failed with exit code ${result.status}.`);
151
+ }
152
+
153
+ async function start(binary, arguments_, environment = process.env) {
154
+ const options = parseStartArguments(arguments_);
155
+ const storage = dataDirectory(process.platform, environment);
156
+ const database = path.join(storage, "workspace.sqlite3");
157
+ initialize(binary, database, storage);
158
+ const url = `http://127.0.0.1:${options.port}`;
159
+ const child = spawn(binary, ["--db", database, "serve"], {
160
+ env: { ...environment, CLONE_HOST: "127.0.0.1", PORT: options.port },
161
+ stdio: "inherit",
162
+ });
163
+ const errorState = { value: null };
164
+ child.once("error", (error) => { errorState.value = error; });
165
+ for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => child.kill(signal));
166
+ try {
167
+ await waitForServer(url, child, errorState);
168
+ } catch (error) {
169
+ child.kill("SIGTERM");
170
+ throw error;
171
+ }
172
+ console.log(`Open ${url}. Stop with Ctrl+C.`);
173
+ if (options.open) openBrowser(url);
174
+ return new Promise((resolve) => {
175
+ child.once("exit", (code, signal) => resolve(signal ? 1 : (code || 0)));
176
+ });
177
+ }
178
+
179
+ function help(storage = null) {
180
+ console.log(`Clone ${packageMetadata.version}
181
+
182
+ Usage:
183
+ clone [--local | --data-dir PATH] [start] [--port PORT] [--no-open]
184
+ clone [--local | --data-dir PATH] <CLI command> [options]
185
+
186
+ Examples:
187
+ npx @andrestobelem/clone
188
+ npx @andrestobelem/clone --local
189
+ npx @andrestobelem/clone --data-dir ./project-data
190
+ npx @andrestobelem/clone start --port 5000
191
+ npx @andrestobelem/clone teams list --json
192
+
193
+ Data: ${dataDirectory(process.platform, process.env, storage)}`);
194
+ }
195
+
196
+ async function main(arguments_ = process.argv.slice(2)) {
197
+ const launcher = parseLauncherArguments(arguments_);
198
+ arguments_ = launcher.arguments;
199
+ if (arguments_.includes("--version") || arguments_.includes("-V")) {
200
+ console.log(packageMetadata.version);
201
+ return 0;
202
+ }
203
+ if (arguments_.includes("--help") || arguments_.includes("-h")) {
204
+ help(launcher.storage);
205
+ return 0;
206
+ }
207
+ const binary = await binaryPath();
208
+ const command = arguments_[0];
209
+ if (!command || command === "start") {
210
+ const environment = launcher.storage ? { ...process.env, CLONE_DATA_DIR: launcher.storage } : process.env;
211
+ return start(binary, command ? arguments_.slice(1) : arguments_, environment);
212
+ }
213
+ const storage = dataDirectory(process.platform, process.env, launcher.storage);
214
+ const database = path.join(storage, "workspace.sqlite3");
215
+ initialize(binary, database, storage);
216
+ const result = spawnSync(binary, ["--db", database, ...arguments_], { stdio: "inherit" });
217
+ if (result.error) throw result.error;
218
+ return result.status || 0;
219
+ }
220
+
221
+ if (require.main === module) {
222
+ main().then((code) => { process.exitCode = code; }).catch((error) => {
223
+ console.error(`Clone: ${error.message}`);
224
+ process.exitCode = 1;
225
+ });
226
+ }
227
+
228
+ module.exports = { cacheDirectory, dataDirectory, parseLauncherArguments, parseStartArguments, target };
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "@andrestobelem/clone",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Run the Clone issue and project workspace locally.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/andrestobelem/clone.git"
9
+ },
10
+ "homepage": "https://clone-andrestobelem-prod.web.app",
11
+ "bugs": "https://github.com/andrestobelem/clone/issues",
12
+ "type": "commonjs",
13
+ "bin": {
14
+ "clone": "bin/clone.js"
15
+ },
16
+ "files": [
17
+ "bin/clone.js"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "provenance": true
25
+ }
26
+ }