@masslessai/push-todo 3.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.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Machine Identification for Push CLI.
3
+ *
4
+ * Generates and persists a unique machine identifier used for:
5
+ * - Atomic task claiming (prevents multi-Mac race conditions)
6
+ * - Task attribution (which Mac executed a task)
7
+ * - Worktree naming (prevents branch conflicts)
8
+ *
9
+ * File location: ~/.config/push/machine_id
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
13
+ import { homedir, hostname, platform, release, version, arch } from 'os';
14
+ import { join, dirname } from 'path';
15
+ import { randomUUID } from 'crypto';
16
+
17
+ const MACHINE_ID_FILE = join(homedir(), '.config', 'push', 'machine_id');
18
+
19
+ /**
20
+ * Get or create a unique machine identifier.
21
+ *
22
+ * Format: "{hostname}-{random_hex}"
23
+ * Example: "Yuxiang-MacBook-Pro-a1b2c3d4"
24
+ *
25
+ * The ID is persisted to disk and reused across sessions.
26
+ *
27
+ * @returns {string} Unique machine identifier
28
+ */
29
+ export function getMachineId() {
30
+ // Try to read existing ID
31
+ if (existsSync(MACHINE_ID_FILE)) {
32
+ try {
33
+ const storedId = readFileSync(MACHINE_ID_FILE, 'utf8').trim();
34
+ if (storedId) {
35
+ return storedId;
36
+ }
37
+ } catch {
38
+ // Fall through to generate new ID
39
+ }
40
+ }
41
+
42
+ // Generate new ID: hostname + random suffix
43
+ const host = hostname(); // e.g., "Yuxiang-MacBook-Pro"
44
+ const randomSuffix = randomUUID().slice(0, 8); // e.g., "a1b2c3d4"
45
+ const machineId = `${host}-${randomSuffix}`;
46
+
47
+ // Persist to disk
48
+ try {
49
+ mkdirSync(dirname(MACHINE_ID_FILE), { recursive: true });
50
+ writeFileSync(MACHINE_ID_FILE, machineId);
51
+ } catch {
52
+ // If we can't persist, still return the ID for this session
53
+ }
54
+
55
+ return machineId;
56
+ }
57
+
58
+ /**
59
+ * Get human-readable machine name.
60
+ *
61
+ * Returns the hostname without the random suffix.
62
+ * Example: "Yuxiang-MacBook-Pro"
63
+ *
64
+ * @returns {string} Human-readable machine name
65
+ */
66
+ export function getMachineName() {
67
+ return hostname();
68
+ }
69
+
70
+ /**
71
+ * Get full machine information for debugging.
72
+ *
73
+ * @returns {Object} Machine info
74
+ */
75
+ export function getMachineInfo() {
76
+ return {
77
+ machineId: getMachineId(),
78
+ machineName: getMachineName(),
79
+ platform: platform(), // "darwin" for macOS
80
+ release: release(), // OS kernel version
81
+ version: version(), // OS version string
82
+ arch: arch(), // "arm64" or "x64"
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Delete the stored machine ID (for testing/debugging).
88
+ * The next call to getMachineId() will generate a new ID.
89
+ */
90
+ export function resetMachineId() {
91
+ if (existsSync(MACHINE_ID_FILE)) {
92
+ try {
93
+ const { unlinkSync } = require('fs');
94
+ unlinkSync(MACHINE_ID_FILE);
95
+ } catch {
96
+ // Ignore errors
97
+ }
98
+ }
99
+ }
100
+
101
+ export { MACHINE_ID_FILE };
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Project Registry for Push CLI.
3
+ *
4
+ * Maps git_remote to local paths for global daemon routing.
5
+ * Enables the daemon to route tasks to the correct project directory.
6
+ *
7
+ * File location: ~/.config/push/projects.json
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs';
11
+ import { homedir } from 'os';
12
+ import { join } from 'path';
13
+
14
+ const REGISTRY_FILE = join(homedir(), '.config', 'push', 'projects.json');
15
+ const REGISTRY_VERSION = 1;
16
+
17
+ /**
18
+ * Project Registry class.
19
+ * Manages the local project registry for global daemon routing.
20
+ */
21
+ class ProjectRegistry {
22
+ constructor() {
23
+ this._ensureConfigDir();
24
+ this._data = this._load();
25
+ }
26
+
27
+ _ensureConfigDir() {
28
+ const dir = join(homedir(), '.config', 'push');
29
+ mkdirSync(dir, { recursive: true });
30
+ }
31
+
32
+ _load() {
33
+ if (!existsSync(REGISTRY_FILE)) {
34
+ return {
35
+ version: REGISTRY_VERSION,
36
+ projects: {},
37
+ defaultProject: null
38
+ };
39
+ }
40
+
41
+ try {
42
+ const content = readFileSync(REGISTRY_FILE, 'utf8');
43
+ const data = JSON.parse(content);
44
+
45
+ // Migration: handle older versions if needed
46
+ if ((data.version || 0) < REGISTRY_VERSION) {
47
+ return this._migrate(data);
48
+ }
49
+
50
+ return data;
51
+ } catch {
52
+ return {
53
+ version: REGISTRY_VERSION,
54
+ projects: {},
55
+ defaultProject: null
56
+ };
57
+ }
58
+ }
59
+
60
+ _save() {
61
+ writeFileSync(REGISTRY_FILE, JSON.stringify(this._data, null, 2));
62
+ }
63
+
64
+ _migrate(data) {
65
+ // Future: handle migrations as needed
66
+ data.version = REGISTRY_VERSION;
67
+ return data;
68
+ }
69
+
70
+ /**
71
+ * Register a project.
72
+ *
73
+ * @param {string} gitRemote - Normalized git remote (e.g., "github.com/user/repo")
74
+ * @param {string} localPath - Absolute local path
75
+ * @returns {boolean} True if newly registered, false if updated existing
76
+ */
77
+ register(gitRemote, localPath) {
78
+ const isNew = !(gitRemote in this._data.projects);
79
+ const now = new Date().toISOString();
80
+
81
+ if (isNew) {
82
+ this._data.projects[gitRemote] = {
83
+ localPath,
84
+ registeredAt: now,
85
+ lastUsed: now
86
+ };
87
+ } else {
88
+ this._data.projects[gitRemote].localPath = localPath;
89
+ this._data.projects[gitRemote].lastUsed = now;
90
+ }
91
+
92
+ // Set as default if first project
93
+ if (this._data.defaultProject === null) {
94
+ this._data.defaultProject = gitRemote;
95
+ }
96
+
97
+ this._save();
98
+ return isNew;
99
+ }
100
+
101
+ /**
102
+ * Get local path for a git remote.
103
+ * Updates lastUsed timestamp.
104
+ *
105
+ * @param {string} gitRemote - Normalized git remote
106
+ * @returns {string|null} Local path or null if not registered
107
+ */
108
+ getPath(gitRemote) {
109
+ const project = this._data.projects[gitRemote];
110
+ if (project) {
111
+ // Update last_used
112
+ project.lastUsed = new Date().toISOString();
113
+ this._save();
114
+ return project.localPath;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Get local path without updating lastUsed.
121
+ * Useful for status checks and listing operations.
122
+ *
123
+ * @param {string} gitRemote - Normalized git remote
124
+ * @returns {string|null} Local path or null if not registered
125
+ */
126
+ getPathWithoutUpdate(gitRemote) {
127
+ const project = this._data.projects[gitRemote];
128
+ return project ? project.localPath : null;
129
+ }
130
+
131
+ /**
132
+ * List all registered projects.
133
+ *
134
+ * @returns {Object} Dict of gitRemote -> localPath
135
+ */
136
+ listProjects() {
137
+ const result = {};
138
+ for (const [remote, info] of Object.entries(this._data.projects)) {
139
+ result[remote] = info.localPath;
140
+ }
141
+ return result;
142
+ }
143
+
144
+ /**
145
+ * List all registered projects with full metadata.
146
+ *
147
+ * @returns {Object} Dict of gitRemote -> {localPath, registeredAt, lastUsed}
148
+ */
149
+ listProjectsWithMetadata() {
150
+ return { ...this._data.projects };
151
+ }
152
+
153
+ /**
154
+ * Unregister a project.
155
+ *
156
+ * @param {string} gitRemote - Normalized git remote
157
+ * @returns {boolean} True if was registered, false if not found
158
+ */
159
+ unregister(gitRemote) {
160
+ if (gitRemote in this._data.projects) {
161
+ delete this._data.projects[gitRemote];
162
+
163
+ if (this._data.defaultProject === gitRemote) {
164
+ // Set new default
165
+ const remaining = Object.keys(this._data.projects);
166
+ this._data.defaultProject = remaining.length > 0 ? remaining[0] : null;
167
+ }
168
+
169
+ this._save();
170
+ return true;
171
+ }
172
+ return false;
173
+ }
174
+
175
+ /**
176
+ * Get the default project's git remote.
177
+ *
178
+ * @returns {string|null}
179
+ */
180
+ getDefaultProject() {
181
+ return this._data.defaultProject;
182
+ }
183
+
184
+ /**
185
+ * Set a project as the default.
186
+ *
187
+ * @param {string} gitRemote
188
+ * @returns {boolean} True if successful
189
+ */
190
+ setDefaultProject(gitRemote) {
191
+ if (gitRemote in this._data.projects) {
192
+ this._data.defaultProject = gitRemote;
193
+ this._save();
194
+ return true;
195
+ }
196
+ return false;
197
+ }
198
+
199
+ /**
200
+ * Return the number of registered projects.
201
+ *
202
+ * @returns {number}
203
+ */
204
+ projectCount() {
205
+ return Object.keys(this._data.projects).length;
206
+ }
207
+
208
+ /**
209
+ * Check if a project is registered.
210
+ *
211
+ * @param {string} gitRemote
212
+ * @returns {boolean}
213
+ */
214
+ isRegistered(gitRemote) {
215
+ return gitRemote in this._data.projects;
216
+ }
217
+
218
+ /**
219
+ * Validate that all registered paths still exist.
220
+ *
221
+ * @returns {Array} List of invalid entries
222
+ */
223
+ validatePaths() {
224
+ const invalid = [];
225
+
226
+ for (const [gitRemote, info] of Object.entries(this._data.projects)) {
227
+ const path = info.localPath;
228
+
229
+ try {
230
+ const stats = statSync(path);
231
+ if (!stats.isDirectory()) {
232
+ invalid.push({
233
+ gitRemote,
234
+ localPath: path,
235
+ reason: 'not_a_directory'
236
+ });
237
+ } else if (!existsSync(join(path, '.git'))) {
238
+ invalid.push({
239
+ gitRemote,
240
+ localPath: path,
241
+ reason: 'not_a_git_repo'
242
+ });
243
+ }
244
+ } catch {
245
+ invalid.push({
246
+ gitRemote,
247
+ localPath: path,
248
+ reason: 'path_not_found'
249
+ });
250
+ }
251
+ }
252
+
253
+ return invalid;
254
+ }
255
+ }
256
+
257
+ // Singleton instance
258
+ let _registry = null;
259
+
260
+ /**
261
+ * Get the singleton registry instance.
262
+ *
263
+ * @returns {ProjectRegistry}
264
+ */
265
+ export function getRegistry() {
266
+ if (_registry === null) {
267
+ _registry = new ProjectRegistry();
268
+ }
269
+ return _registry;
270
+ }
271
+
272
+ /**
273
+ * Reset the singleton (for testing).
274
+ */
275
+ export function resetRegistry() {
276
+ _registry = null;
277
+ }
278
+
279
+ export { ProjectRegistry, REGISTRY_FILE };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Terminal color utilities for Push CLI.
3
+ *
4
+ * ANSI escape codes for terminal styling.
5
+ * Automatically disables colors when not outputting to a TTY.
6
+ */
7
+
8
+ const isColorEnabled = process.stdout.isTTY && !process.env.NO_COLOR;
9
+
10
+ /**
11
+ * ANSI escape codes.
12
+ */
13
+ export const codes = {
14
+ // Reset
15
+ reset: '\x1b[0m',
16
+
17
+ // Styles
18
+ bold: '\x1b[1m',
19
+ dim: '\x1b[2m',
20
+ italic: '\x1b[3m',
21
+ underline: '\x1b[4m',
22
+
23
+ // Colors
24
+ black: '\x1b[30m',
25
+ red: '\x1b[31m',
26
+ green: '\x1b[32m',
27
+ yellow: '\x1b[33m',
28
+ blue: '\x1b[34m',
29
+ magenta: '\x1b[35m',
30
+ cyan: '\x1b[36m',
31
+ white: '\x1b[37m',
32
+
33
+ // Bright colors
34
+ brightBlack: '\x1b[90m',
35
+ brightRed: '\x1b[91m',
36
+ brightGreen: '\x1b[92m',
37
+ brightYellow: '\x1b[93m',
38
+ brightBlue: '\x1b[94m',
39
+ brightMagenta: '\x1b[95m',
40
+ brightCyan: '\x1b[96m',
41
+ brightWhite: '\x1b[97m',
42
+
43
+ // Background colors
44
+ bgBlack: '\x1b[40m',
45
+ bgRed: '\x1b[41m',
46
+ bgGreen: '\x1b[42m',
47
+ bgYellow: '\x1b[43m',
48
+ bgBlue: '\x1b[44m',
49
+ bgMagenta: '\x1b[45m',
50
+ bgCyan: '\x1b[46m',
51
+ bgWhite: '\x1b[47m',
52
+
53
+ // Cursor control
54
+ clearScreen: '\x1b[2J',
55
+ cursorHome: '\x1b[H',
56
+ hideCursor: '\x1b[?25l',
57
+ showCursor: '\x1b[?25h',
58
+ clearLine: '\x1b[2K',
59
+ cursorUp: '\x1b[1A',
60
+ cursorDown: '\x1b[1B',
61
+ };
62
+
63
+ /**
64
+ * Wrap text with color codes.
65
+ *
66
+ * @param {string} text - Text to color
67
+ * @param {string} code - ANSI code
68
+ * @returns {string} Colored text (or plain text if colors disabled)
69
+ */
70
+ function wrap(text, code) {
71
+ if (!isColorEnabled) {
72
+ return text;
73
+ }
74
+ return `${code}${text}${codes.reset}`;
75
+ }
76
+
77
+ // Style functions
78
+ export const bold = (text) => wrap(text, codes.bold);
79
+ export const dim = (text) => wrap(text, codes.dim);
80
+ export const italic = (text) => wrap(text, codes.italic);
81
+ export const underline = (text) => wrap(text, codes.underline);
82
+
83
+ // Color functions
84
+ export const red = (text) => wrap(text, codes.red);
85
+ export const green = (text) => wrap(text, codes.green);
86
+ export const yellow = (text) => wrap(text, codes.yellow);
87
+ export const blue = (text) => wrap(text, codes.blue);
88
+ export const magenta = (text) => wrap(text, codes.magenta);
89
+ export const cyan = (text) => wrap(text, codes.cyan);
90
+ export const white = (text) => wrap(text, codes.white);
91
+
92
+ // Bright color functions
93
+ export const brightRed = (text) => wrap(text, codes.brightRed);
94
+ export const brightGreen = (text) => wrap(text, codes.brightGreen);
95
+ export const brightYellow = (text) => wrap(text, codes.brightYellow);
96
+ export const brightBlue = (text) => wrap(text, codes.brightBlue);
97
+ export const brightCyan = (text) => wrap(text, codes.brightCyan);
98
+
99
+ // Combined styles
100
+ export const error = (text) => wrap(text, codes.red);
101
+ export const success = (text) => wrap(text, codes.green);
102
+ export const warning = (text) => wrap(text, codes.yellow);
103
+ export const info = (text) => wrap(text, codes.cyan);
104
+ export const muted = (text) => wrap(text, codes.dim);
105
+
106
+ /**
107
+ * Status indicator symbols with colors.
108
+ */
109
+ export const symbols = {
110
+ running: isColorEnabled ? `${codes.green}●${codes.reset}` : '*',
111
+ queued: isColorEnabled ? `${codes.yellow}○${codes.reset}` : 'o',
112
+ completed: isColorEnabled ? `${codes.green}✓${codes.reset}` : '+',
113
+ failed: isColorEnabled ? `${codes.red}✗${codes.reset}` : 'x',
114
+ timeout: isColorEnabled ? `${codes.red}⏱${codes.reset}` : 'T',
115
+ backlog: isColorEnabled ? `${codes.blue}📦${codes.reset}` : '[B]',
116
+ pinned: isColorEnabled ? `${codes.yellow}📌${codes.reset}` : '[P]',
117
+ };
118
+
119
+ /**
120
+ * Check if colors are enabled.
121
+ *
122
+ * @returns {boolean}
123
+ */
124
+ export function colorsEnabled() {
125
+ return isColorEnabled;
126
+ }