tux-review 0.1.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 (60) hide show
  1. package/README.md +67 -0
  2. package/bin/tux.js +17 -0
  3. package/client/tux-review.css +155 -0
  4. package/client/tux-review.js +586 -0
  5. package/package.json +59 -0
  6. package/skills/tux-design-create.md +50 -0
  7. package/skills/tux-design-incorporate.md +61 -0
  8. package/skills/tux-design-install.md +56 -0
  9. package/skills/tux-design-start-review.md +51 -0
  10. package/skills/tux-design-stop-review.md +44 -0
  11. package/skills/tux-feedback-delete.md +45 -0
  12. package/skills/tux-feedback-export.md +47 -0
  13. package/skills/tux-feedback-show.md +55 -0
  14. package/skills/tux-live-create.md +50 -0
  15. package/skills/tux-live-incorporate.md +62 -0
  16. package/skills/tux-live-install.md +63 -0
  17. package/skills/tux-live-start-review.md +47 -0
  18. package/skills/tux-live-stop-review.md +46 -0
  19. package/src/canonical.js +28 -0
  20. package/src/cli.js +229 -0
  21. package/src/config.js +72 -0
  22. package/src/design.js +140 -0
  23. package/src/errors.js +28 -0
  24. package/src/feedback.js +289 -0
  25. package/src/ids.js +57 -0
  26. package/src/index.js +17 -0
  27. package/src/live.js +261 -0
  28. package/src/schema.js +77 -0
  29. package/src/serve-main.js +42 -0
  30. package/src/server.js +347 -0
  31. package/src/store.js +72 -0
  32. package/src/templates.js +44 -0
  33. package/templates/angular/README.md +19 -0
  34. package/templates/angular/angular.json +49 -0
  35. package/templates/angular/package.json +25 -0
  36. package/templates/angular/src/app/app.component.html +40 -0
  37. package/templates/angular/src/app/app.component.ts +30 -0
  38. package/templates/angular/src/index.html +11 -0
  39. package/templates/angular/src/main.ts +4 -0
  40. package/templates/angular/src/styles.css +11 -0
  41. package/templates/angular/tsconfig.app.json +8 -0
  42. package/templates/angular/tsconfig.json +11 -0
  43. package/templates/react/README.md +18 -0
  44. package/templates/react/index.html +12 -0
  45. package/templates/react/package.json +18 -0
  46. package/templates/react/src/App.jsx +80 -0
  47. package/templates/react/src/main.jsx +6 -0
  48. package/templates/react/src/styles.css +11 -0
  49. package/templates/vanilla/README.md +22 -0
  50. package/templates/vanilla/index.html +18 -0
  51. package/templates/vanilla/package.json +6 -0
  52. package/templates/vanilla/src/app.js +79 -0
  53. package/templates/vanilla/src/styles.css +11 -0
  54. package/templates/vue/README.md +18 -0
  55. package/templates/vue/index.html +12 -0
  56. package/templates/vue/package.json +17 -0
  57. package/templates/vue/src/App.vue +61 -0
  58. package/templates/vue/src/main.js +5 -0
  59. package/templates/vue/src/styles.css +11 -0
  60. package/templates/vue/vite.config.js +6 -0
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Feedback operations — the deterministic primitives behind
3
+ * `tux feedback <action>` (SPC sections 42–57).
4
+ *
5
+ * @module feedback
6
+ */
7
+ import { CliError, EXIT } from './errors.js';
8
+ import { canonicalJson, compactJson } from './canonical.js';
9
+ import { nowMs, canonicalTimestamp, newId } from './ids.js';
10
+ import { FEEDBACK_TYPES, FEEDBACK_STATUSES, makeFeedback, normalizedText, validateFeedback } from './schema.js';
11
+ import {
12
+ loadStore, saveStore, storeExists, ensureSession,
13
+ countIncorporationBatches, saveIncorporationBatch,
14
+ } from './store.js';
15
+ import { resolveIdentity } from './config.js';
16
+
17
+ /** Options shared by every action: {cwd, config, identity, format, time}. */
18
+ function ctx(opts) {
19
+ return {
20
+ cwd: opts.cwd,
21
+ config: opts.config,
22
+ identity: opts.identity ?? resolveIdentity(opts.config),
23
+ format: opts.format ?? 'text',
24
+ now: opts.now ?? nowMs(),
25
+ timestamp: canonicalTimestamp(opts.now ?? nowMs()),
26
+ };
27
+ }
28
+
29
+ /**
30
+ * `tux feedback show [feedback-id]` (SPC section 42): with an ID, print
31
+ * the complete canonical item (always canonical JSON, filters ignored);
32
+ * without an ID, survey all items with composing filters.
33
+ */
34
+ export function opShow(opts, id, filters = {}) {
35
+ const c = ctx(opts);
36
+ const store = loadStore(c.cwd, c.config);
37
+ if (id) {
38
+ const item = store.feedback.find((f) => f.id === id);
39
+ if (!item) throw new CliError(EXIT.notFound, `feedback not found: ${id}`);
40
+ return { stdout: canonicalJson(item) + '\n', exit: EXIT.ok };
41
+ }
42
+ let items = store.feedback;
43
+ if (filters.status) items = items.filter((f) => f.status === filters.status);
44
+ if (filters.type) items = items.filter((f) => f.feedback.type === filters.type);
45
+ if (filters.mine) items = items.filter((f) => f.author.user_id === c.identity.user_id);
46
+ if (filters.route) items = items.filter((f) => f.location?.route === filters.route);
47
+ if (filters.session) items = items.filter((f) => f.session_id === filters.session);
48
+ if (filters.origin) items = items.filter((f) => f.origin?.mode === filters.origin);
49
+ if (c.format === 'json') return { stdout: canonicalJson(items) + '\n', exit: EXIT.ok };
50
+ const lines = items.map((f) =>
51
+ [f.id, f.status, f.feedback.type, f.location?.route ?? '-', firstLine(f.feedback.text)].join('\t')
52
+ );
53
+ return { stdout: (lines.length ? lines.join('\n') + '\n' : ''), exit: EXIT.ok };
54
+ }
55
+
56
+ export function opCreate(opts, spec) {
57
+ const c = ctx(opts);
58
+ if (!FEEDBACK_TYPES.includes(spec.type)) {
59
+ throw new CliError(EXIT.usage, `invalid feedback type: ${spec.type} (expected ${FEEDBACK_TYPES.join(', ')})`);
60
+ }
61
+ if (!spec.text || spec.text.trim() === '') throw new CliError(EXIT.usage, '--text is required');
62
+ const store = loadStore(c.cwd, c.config);
63
+ const seq = store.feedback.length + 1;
64
+ const id = newId('fb', store.project_id, spec.session ?? 'default', seq, 'feedback', c.now);
65
+ const item = makeFeedback({
66
+ id,
67
+ project_id: store.project_id,
68
+ session_id: spec.session ?? 'default',
69
+ author: { user_id: c.identity.user_id, display_name: c.identity.display_name },
70
+ origin: spec.origin ?? 'design',
71
+ location: {
72
+ ...(spec.route ? { route: spec.route } : {}),
73
+ ...(spec.page ? { page: spec.page } : {}),
74
+ ...(spec.component ? { component: spec.component } : {}),
75
+ ...(spec.instance ? { component_instance: spec.instance } : {}),
76
+ },
77
+ target: {
78
+ ...(spec.tuxId ? { tux_id: spec.tuxId } : {}),
79
+ ...(spec.testId ? { test_id: spec.testId } : {}),
80
+ },
81
+ ui_state: spec.uiState ?? {},
82
+ type: spec.type,
83
+ text: spec.text,
84
+ created_at: c.timestamp,
85
+ });
86
+ const problems = validateFeedback(item);
87
+ if (problems.length) throw new CliError(EXIT.general, `created item invalid: ${problems.join('; ')}`);
88
+ store.feedback.push(item);
89
+ saveStore(c.cwd, c.config, store);
90
+ if (spec.session) ensureSession(c.cwd, c.config, spec.session, spec.environment ?? 'design', c.timestamp);
91
+ return { stdout: canonicalJson(item) + '\n', exit: EXIT.ok };
92
+ }
93
+
94
+ export function opUpdate(opts, id, patch) {
95
+ const c = ctx(opts);
96
+ const store = loadStore(c.cwd, c.config);
97
+ const item = store.feedback.find((f) => f.id === id);
98
+ if (!item) throw new CliError(EXIT.notFound, `feedback not found: ${id}`);
99
+ if (patch.status !== undefined && !FEEDBACK_STATUSES.includes(patch.status)) {
100
+ throw new CliError(EXIT.usage, `invalid status: ${patch.status} (expected ${FEEDBACK_STATUSES.join(', ')})`);
101
+ }
102
+ if (patch.type !== undefined && !FEEDBACK_TYPES.includes(patch.type)) {
103
+ throw new CliError(EXIT.usage, `invalid feedback type: ${patch.type} (expected ${FEEDBACK_TYPES.join(', ')})`);
104
+ }
105
+ if (patch.text !== undefined) item.feedback.text = patch.text;
106
+ if (patch.type !== undefined) item.feedback.type = patch.type;
107
+ if (patch.status !== undefined) item.status = patch.status;
108
+ if (patch.incorporation !== undefined) item.incorporation = patch.incorporation;
109
+ item.updated_at = c.timestamp;
110
+ saveStore(c.cwd, c.config, store);
111
+ return { stdout: canonicalJson(item) + '\n', exit: EXIT.ok };
112
+ }
113
+
114
+ export function opDelete(opts, id) {
115
+ const c = ctx(opts);
116
+ const store = loadStore(c.cwd, c.config);
117
+ const idx = store.feedback.findIndex((f) => f.id === id);
118
+ if (idx === -1) throw new CliError(EXIT.notFound, `feedback not found: ${id}`);
119
+ store.feedback.splice(idx, 1);
120
+ saveStore(c.cwd, c.config, store);
121
+ if (c.format === 'json') return { stdout: canonicalJson({ deleted: [id] }) + '\n', exit: EXIT.ok };
122
+ return { stdout: `deleted ${id}\n`, exit: EXIT.ok };
123
+ }
124
+
125
+ export function opClear(opts, scope, filters) {
126
+ const c = ctx(opts);
127
+ if (scope === 'all' && !filters.force) {
128
+ throw new CliError(EXIT.usage, '--all requires explicit confirmation (--force)');
129
+ }
130
+ if (!scope) throw new CliError(EXIT.usage, 'specify --mine or --all');
131
+ const store = loadStore(c.cwd, c.config);
132
+ const before = store.feedback.length;
133
+ store.feedback = store.feedback.filter((f) => {
134
+ if (scope === 'mine' && f.author.user_id !== c.identity.user_id) return true;
135
+ if (filters.route && f.location?.route !== filters.route) return true;
136
+ if (filters.session && f.session_id !== filters.session) return true;
137
+ return false;
138
+ });
139
+ const cleared = before - store.feedback.length;
140
+ saveStore(c.cwd, c.config, store);
141
+ if (c.format === 'json') return { stdout: canonicalJson({ cleared }) + '\n', exit: EXIT.ok };
142
+ return { stdout: `cleared ${cleared}\n`, exit: EXIT.ok };
143
+ }
144
+
145
+ export function opExport(opts, format) {
146
+ const c = ctx(opts);
147
+ const store = loadStore(c.cwd, c.config);
148
+ if (format === 'jsonl') {
149
+ const out = store.feedback.map((f) => compactJson(f)).join('\n');
150
+ return { stdout: (out ? out + '\n' : ''), exit: EXIT.ok };
151
+ }
152
+ return { stdout: canonicalJson(store.feedback) + '\n', exit: EXIT.ok };
153
+ }
154
+
155
+ export function storeIsEmpty(opts) {
156
+ return !storeExists(opts.cwd, opts.config);
157
+ }
158
+
159
+ /** Canonical target key for grouping (SPC section 50). */
160
+ export function targetKey(item) {
161
+ return [
162
+ item.location?.route ?? '',
163
+ item.location?.page ?? '',
164
+ item.location?.component ?? '',
165
+ item.location?.component_instance ?? '',
166
+ item.target?.tux_id ?? '',
167
+ item.target?.test_id ?? '',
168
+ ].join('|');
169
+ }
170
+
171
+ export function opIncorporate(opts, strategy, filters) {
172
+ const c = ctx(opts);
173
+ const STRATEGIES = ['consolidate', 'requirements', 'tasks', 'direct', 'export-only'];
174
+ if (strategy === undefined) {
175
+ throw new CliError(EXIT.usage, `--strategy is required in non-interactive mode (one of ${STRATEGIES.join(', ')})`);
176
+ }
177
+ if (!STRATEGIES.includes(strategy)) {
178
+ throw new CliError(EXIT.usage, `invalid strategy: ${strategy} (expected ${STRATEGIES.join(', ')})`);
179
+ }
180
+ const store = loadStore(c.cwd, c.config);
181
+ let open = store.feedback.filter((f) => f.status === 'open');
182
+ if (filters.mine) open = open.filter((f) => f.author.user_id === c.identity.user_id);
183
+ if (filters.route) open = open.filter((f) => f.location?.route === filters.route);
184
+ if (filters.session) open = open.filter((f) => f.session_id === filters.session);
185
+ if (filters.origin) open = open.filter((f) => f.origin?.mode === filters.origin);
186
+
187
+ const groups = [];
188
+ const byKey = new Map();
189
+ for (const item of open) {
190
+ const key = targetKey(item);
191
+ if (!byKey.has(key)) {
192
+ const group = { key, items: [] };
193
+ byKey.set(key, group);
194
+ groups.push(group);
195
+ }
196
+ byKey.get(key).items.push(item);
197
+ }
198
+
199
+ const reportGroups = [];
200
+ const incorporated = [];
201
+ const conflicts = [];
202
+ for (const group of groups) {
203
+ const items = group.items;
204
+ const kinds = new Set(items.map((i) => i.feedback.type));
205
+ const isConflict = kinds.has('approval') && (kinds.has('change') || kinds.has('issue') || kinds.has('question'));
206
+ if (isConflict) {
207
+ reportGroups.push({ key: group.key, kind: 'conflict', requires_decision: true, feedback_ids: items.map((i) => i.id) });
208
+ for (const i of items) conflicts.push(i.id);
209
+ continue;
210
+ }
211
+ let kind = 'unique';
212
+ if (items.length > 1) {
213
+ const first = normalizedText(items[0].feedback.text);
214
+ kind = items.every((i) => normalizedText(i.feedback.text) === first) ? 'duplicate' : 'unique';
215
+ }
216
+ reportGroups.push({ key: group.key, kind, requires_decision: false, feedback_ids: items.map((i) => i.id) });
217
+ if (strategy !== 'export-only') {
218
+ for (const i of items) {
219
+ i.status = 'incorporated';
220
+ i.incorporation = { strategy, batch_id: null, recorded_at: c.timestamp };
221
+ incorporated.push(i.id);
222
+ }
223
+ }
224
+ }
225
+
226
+ const seq = countIncorporationBatches(c.cwd) + 1;
227
+ const batchId = newId('batch', store.project_id, 'default', seq, 'batch', c.now);
228
+ const report = {
229
+ schema_version: '1.0',
230
+ batch_id: batchId,
231
+ strategy,
232
+ created_at: c.timestamp,
233
+ groups: reportGroups,
234
+ incorporated,
235
+ conflicts,
236
+ };
237
+ if (strategy !== 'export-only') {
238
+ for (const item of store.feedback) {
239
+ if (item.incorporation?.batch_id === null && incorporated.includes(item.id)) {
240
+ item.incorporation.batch_id = batchId;
241
+ }
242
+ }
243
+ saveStore(c.cwd, c.config, store);
244
+ saveIncorporationBatch(c.cwd, report);
245
+ }
246
+ return { stdout: canonicalJson(report) + '\n', exit: EXIT.ok };
247
+ }
248
+
249
+ export function opValidate(opts, args) {
250
+ const c = ctx(opts);
251
+ if (args.record !== undefined) {
252
+ if (!['passed', 'failed'].includes(args.result)) {
253
+ throw new CliError(EXIT.usage, `invalid validation result: ${args.result} (expected passed, failed)`);
254
+ }
255
+ const store = loadStore(c.cwd, c.config);
256
+ const item = store.feedback.find((f) => f.id === args.record);
257
+ if (!item) throw new CliError(EXIT.notFound, `feedback not found: ${args.record}`);
258
+ item.validation = { result: args.result, checked_at: c.timestamp, ...(args.note ? { note: args.note } : {}) };
259
+ item.updated_at = c.timestamp;
260
+ saveStore(c.cwd, c.config, store);
261
+ return { stdout: canonicalJson(item) + '\n', exit: EXIT.ok };
262
+ }
263
+ const store = loadStore(c.cwd, c.config);
264
+ let items = store.feedback
265
+ .filter((f) => f.status === 'incorporated')
266
+ .filter((f) => !args.origin || f.origin?.mode === args.origin)
267
+ .map((f) => ({
268
+ id: f.id,
269
+ location_route: f.location?.route ?? null,
270
+ validation_result: f.validation?.result ?? 'unvalidated',
271
+ }));
272
+ const summary = {
273
+ total: items.length,
274
+ passed: items.filter((i) => i.validation_result === 'passed').length,
275
+ failed: items.filter((i) => i.validation_result === 'failed').length,
276
+ unvalidated: items.filter((i) => i.validation_result === 'unvalidated').length,
277
+ };
278
+ let exit = EXIT.ok;
279
+ if (args.strict && (summary.failed > 0 || summary.unvalidated > 0)) exit = EXIT.general;
280
+ if (c.format === 'json') {
281
+ return { stdout: canonicalJson({ schema_version: '1.0', summary, items }) + '\n', exit };
282
+ }
283
+ const lines = items.map((i) => [i.id, i.validation_result].join('\t'));
284
+ return { stdout: (lines.length ? lines.join('\n') + '\n' : '') + `total ${summary.total}, passed ${summary.passed}, failed ${summary.failed}, unvalidated ${summary.unvalidated}\n`, exit };
285
+ }
286
+
287
+ function firstLine(text) {
288
+ return String(text).split('\n')[0];
289
+ }
package/src/ids.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Deterministic identifiers and timestamps.
3
+ *
4
+ * IDs are ULIDs (Crockford base32, 26 chars): 48-bit millisecond timestamp
5
+ * plus 80-bit entropy. For cross-runtime byte-identical output the
6
+ * timestamp comes from `TUX_TIME_OVERRIDE` (ISO 8601; naive values are
7
+ * treated as UTC) and the entropy is the first 10 bytes of
8
+ * `SHA-256("<project_id>|<session_id>|<seq>|<kind>")` where `seq` is the
9
+ * 1-based next sequence number in the target store.
10
+ *
11
+ * @module ids
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ import { CliError } from './errors.js';
15
+
16
+ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
17
+
18
+ /** Resolve the effective "now" in epoch milliseconds. */
19
+ export function nowMs() {
20
+ const override = process.env.TUX_TIME_OVERRIDE;
21
+ if (override === undefined) return Date.now();
22
+ let s = override.trim();
23
+ if (!/(?:[zZ]|[+-]\d{2}:?\d{2})$/.test(s)) s += 'Z';
24
+ const t = Date.parse(s);
25
+ if (Number.isNaN(t)) throw new CliError(3, `invalid TUX_TIME_OVERRIDE: ${override}`);
26
+ return t;
27
+ }
28
+
29
+ /** Canonical UTC timestamp with millisecond precision. */
30
+ export function canonicalTimestamp(ms) {
31
+ return new Date(ms).toISOString();
32
+ }
33
+
34
+ /** 80-bit entropy bytes derived deterministically from context. */
35
+ export function deterministicEntropy(projectId, sessionId, seq, kind) {
36
+ const material = `${projectId}|${sessionId}|${seq}|${kind}`;
37
+ return createHash('sha256').update(material, 'utf8').digest().subarray(0, 10);
38
+ }
39
+
40
+ /** Encode 16 bytes (6 time + 10 entropy) as a 26-char Crockford ULID. */
41
+ export function ulid(timeMs, entropy) {
42
+ const buf = Buffer.alloc(16);
43
+ buf.writeUIntBE(Number(timeMs), 0, 6);
44
+ entropy.copy(buf, 6);
45
+ let n = BigInt('0x' + buf.toString('hex'));
46
+ let out = '';
47
+ for (let i = 0; i < 26; i++) {
48
+ out = CROCKFORD[Number(n % 32n)] + out;
49
+ n /= 32n;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /** Deterministic prefixed ID (`fb_…`, `batch_…`). */
55
+ export function newId(prefix, projectId, sessionId, seq, kind, timeMs) {
56
+ return `${prefix}_${ulid(timeMs, deterministicEntropy(projectId, sessionId, seq, kind))}`;
57
+ }
package/src/index.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * TUX — TUX UIX review system (Node reference implementation).
3
+ *
4
+ * Public library surface alongside the `tux` CLI.
5
+ *
6
+ * @module index
7
+ */
8
+ export { canonicalJson, compactJson, sortedObject } from './canonical.js';
9
+ export { nowMs, canonicalTimestamp, ulid, deterministicEntropy, newId } from './ids.js';
10
+ export { SCHEMA_VERSION, FEEDBACK_TYPES, FEEDBACK_STATUSES, validateFeedback, makeFeedback, normalizedText } from './schema.js';
11
+ export { loadConfig, resolveIdentity } from './config.js';
12
+ export { loadStore, saveStore } from './store.js';
13
+ export { opShow, opCreate, opUpdate, opDelete, opClear, opExport, opIncorporate, opValidate } from './feedback.js';
14
+ export { opDesignInstall, opDesignCreate, opLiveCreate, opDesignStart, opDesignStatus, opDesignStop } from './design.js';
15
+ export { opLiveInstall, opLiveStart, opLiveStatus, opLiveStop } from './live.js';
16
+ export { run, parseArgs, VERSION } from './cli.js';
17
+ export { startServer, inject } from './server.js';
package/src/live.js ADDED
@@ -0,0 +1,261 @@
1
+ /**
2
+ * `tux live <action>` — install, create, start-review, status, stop-review (SPC sections 32–41).
3
+ * Also hosts the shared server lifecycle used by `tux design start-review`.
4
+ *
5
+ * @module live
6
+ */
7
+ import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { platform } from 'node:os';
10
+ import { spawn } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { CliError, EXIT } from './errors.js';
13
+ import { canonicalJson } from './canonical.js';
14
+ import { canonicalTimestamp } from './ids.js';
15
+ import { loadStore } from './store.js';
16
+
17
+ const SERVER_STATE_FILE = '.tux/server.json';
18
+
19
+ async function health(url) {
20
+ try {
21
+ const r = await fetch(joinUrl(url, '/api/tux/health'), { signal: AbortSignal.timeout(2000) });
22
+ if (!r.ok) return false;
23
+ await r.json();
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ function joinUrl(base, path) {
31
+ return base.replace(/\/+$/, '') + path;
32
+ }
33
+
34
+ export function opLiveInstall(opts) {
35
+ const cwd = opts.cwd;
36
+ const pkgPath = join(cwd, 'package.json');
37
+ const hasPkg = existsSync(pkgPath);
38
+ const hasIndex = existsSync(join(cwd, 'index.html'));
39
+ if (!hasPkg && !hasIndex) {
40
+ throw new CliError(EXIT.config, 'unsupported setup: no package.json or index.html found in the current directory');
41
+ }
42
+ let pkg = {};
43
+ if (hasPkg) {
44
+ try {
45
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
46
+ } catch {
47
+ throw new CliError(EXIT.config, 'invalid JSON in package.json');
48
+ }
49
+ }
50
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
51
+ let framework = 'vanilla';
52
+ if (deps.next) framework = 'next';
53
+ else if (deps['@angular/core']) framework = 'angular';
54
+ else if (deps.vue) framework = 'vue';
55
+ else if (deps.react) framework = 'react';
56
+ const devCommand = pkg.scripts?.dev ?? null;
57
+ let devPort = 3000;
58
+ if (devCommand) {
59
+ const m = devCommand.match(/--port[= ](\d+)/);
60
+ if (m) devPort = Number(m[1]);
61
+ }
62
+ const report = {
63
+ action: 'install',
64
+ kind: 'live',
65
+ strategy: 'proxy',
66
+ runtime: hasPkg ? 'node' : 'static',
67
+ framework,
68
+ dev_command: devCommand,
69
+ dev_port: hasPkg ? devPort : null,
70
+ next: ['tux live start-review --url http://localhost:' + (hasPkg ? devPort : 8123)],
71
+ };
72
+ if (opts.format === 'text') {
73
+ const lines = [
74
+ `installed live review (strategy ${report.strategy}, framework ${framework})`,
75
+ `next: ${report.next.join(' | ')}`,
76
+ ];
77
+ return { stdout: lines.join('\n') + '\n', exit: EXIT.ok };
78
+ }
79
+ return { stdout: canonicalJson(report) + '\n', exit: EXIT.ok };
80
+ }
81
+
82
+ /**
83
+ * Shared server start for `tux live start-review` and `tux design start-review`.
84
+ * spec: {mode, host, port, session, environment, url, target|null, root|null, dryRun, foreground, plan}
85
+ */
86
+ export async function startServer(opts, spec) {
87
+ if (spec.dryRun) {
88
+ return { stdout: canonicalJson(spec.plan) + '\n', exit: EXIT.ok };
89
+ }
90
+ mkdirSync(join(opts.cwd, '.tux'), { recursive: true });
91
+ let appPid = null;
92
+ if (spec.cmd) {
93
+ appPid = spawnDetached(spec.cmd, opts.cwd);
94
+ const deadline = Date.now() + 30000;
95
+ const targetUp = await waitUntil(() => health(spec.target), deadline);
96
+ if (!targetUp) {
97
+ killTree(appPid);
98
+ throw new CliError(EXIT.server, `target application did not become reachable at ${spec.target}`);
99
+ }
100
+ }
101
+ if (spec.foreground) {
102
+ const { startServer: startInProcess } = await import('./server.js');
103
+ const server = startInProcess({
104
+ mode: spec.mode, host: spec.host, port: spec.port,
105
+ root: spec.root ?? null, target: spec.target ?? null,
106
+ cwd: opts.cwd, session: spec.session, environment: spec.environment, config: opts.config,
107
+ });
108
+ await new Promise((resolvePromise) => server.listen(spec.port, spec.host, resolvePromise));
109
+ const state = serverState(spec, opts, process.pid);
110
+ writeState(opts.cwd, state);
111
+ return { stdout: '', exit: EXIT.ok, keepAlive: server };
112
+ }
113
+ const serveMain = fileURLToPath(new URL('./serve-main.js', import.meta.url));
114
+ const args = [serveMain, '--mode', spec.mode, '--host', String(spec.host), '--port', String(spec.port),
115
+ '--session', spec.session, '--environment', spec.environment,
116
+ '--store', opts.config.review.store, '--project-id', opts.config.project_id, '--cwd', opts.cwd];
117
+ if (spec.root) args.push('--root', spec.root);
118
+ if (spec.target) args.push('--target', spec.target);
119
+ const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', env: process.env });
120
+ child.unref();
121
+ const deadline = Date.now() + 15000;
122
+ const ok = await waitUntil(() => health(spec.url), deadline);
123
+ if (!ok) {
124
+ killTree(child.pid);
125
+ throw new CliError(EXIT.server, `server did not become healthy at ${spec.url}`);
126
+ }
127
+ const state = serverState(spec, opts, child.pid);
128
+ writeState(opts.cwd, state);
129
+ if (opts.format === 'text') {
130
+ return { stdout: `server running at ${spec.url} (pid ${state.pid})\n`, exit: EXIT.ok };
131
+ }
132
+ return { stdout: canonicalJson(state) + '\n', exit: EXIT.ok };
133
+ }
134
+
135
+ function serverState(spec, opts, pid) {
136
+ return {
137
+ pid,
138
+ ...(spec.appPid ? { app_pid: spec.appPid } : {}),
139
+ mode: spec.mode,
140
+ url: spec.url,
141
+ ...(spec.target ? { target_url: spec.target } : {}),
142
+ ...(spec.root ? { root: spec.root } : {}),
143
+ host: spec.host,
144
+ port: spec.port,
145
+ session_id: spec.session,
146
+ environment: spec.environment,
147
+ project_id: opts.config.project_id,
148
+ store: opts.config.review.store,
149
+ started_at: canonicalTimestamp(Date.now()),
150
+ };
151
+ }
152
+
153
+ function writeState(cwd, state) {
154
+ writeFileSync(join(cwd, SERVER_STATE_FILE), canonicalJson(state) + '\n', 'utf8');
155
+ }
156
+
157
+ export async function opLiveStart(opts, spec) {
158
+ const { config } = opts;
159
+ const host = spec.host ?? config.review.host;
160
+ const port = spec.port ?? config.review.port;
161
+ const session = spec.session ?? 'default';
162
+ const environment = spec.environment ?? 'live';
163
+ const mode = 'live';
164
+ const target = spec.url ?? `http://127.0.0.1:${spec.targetPort ?? 3000}`;
165
+ const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
166
+ const plan = {
167
+ action: 'start',
168
+ kind: 'live',
169
+ mode: spec.cmd ? 'spawn' : 'proxy',
170
+ target,
171
+ host,
172
+ port,
173
+ session_id: session,
174
+ environment,
175
+ url,
176
+ ...(spec.cmd ? { command: spec.cmd } : {}),
177
+ };
178
+ return startServer(opts, {
179
+ ...spec, mode, host, port, session, environment, target, url,
180
+ root: null, plan,
181
+ });
182
+ }
183
+
184
+ export async function opLiveStatus(opts) {
185
+ const statePath = join(opts.cwd, SERVER_STATE_FILE);
186
+ const stopped = { state: 'stopped' };
187
+ if (!existsSync(statePath)) {
188
+ if (opts.format === 'json') return { stdout: canonicalJson(stopped) + '\n', exit: EXIT.ok };
189
+ return { stdout: 'stopped\n', exit: EXIT.ok };
190
+ }
191
+ const state = JSON.parse(readFileSync(statePath, 'utf8'));
192
+ if (!(await health(state.url))) {
193
+ if (opts.format === 'json') return { stdout: canonicalJson(stopped) + '\n', exit: EXIT.ok };
194
+ return { stdout: 'stopped\n', exit: EXIT.ok };
195
+ }
196
+ const store = loadStore(opts.cwd, opts.config);
197
+ const result = { state: 'running', ...state, feedback_count: store.feedback.length };
198
+ if (opts.format === 'json') return { stdout: canonicalJson(result) + '\n', exit: EXIT.ok };
199
+ const lines = [
200
+ 'state: running',
201
+ `url: ${state.url}`,
202
+ `session: ${state.session_id}`,
203
+ `feedback: ${store.feedback.length}`,
204
+ ];
205
+ return { stdout: lines.join('\n') + '\n', exit: EXIT.ok };
206
+ }
207
+
208
+ export function opLiveStop(opts) {
209
+ const statePath = join(opts.cwd, SERVER_STATE_FILE);
210
+ if (!existsSync(statePath)) {
211
+ if (opts.format === 'json') return { stdout: canonicalJson({ stopped: false, reason: 'not running' }) + '\n', exit: EXIT.ok };
212
+ return { stdout: 'not running\n', exit: EXIT.ok };
213
+ }
214
+ const state = JSON.parse(readFileSync(statePath, 'utf8'));
215
+ killTree(state.pid);
216
+ if (state.app_pid) killTree(state.app_pid);
217
+ rmSync(statePath);
218
+ if (opts.format === 'json') return { stdout: canonicalJson({ stopped: true, pid: state.pid }) + '\n', exit: EXIT.ok };
219
+ return { stdout: `stopped (pid ${state.pid})\n`, exit: EXIT.ok };
220
+ }
221
+
222
+ function spawnDetached(cmd, cwd) {
223
+ const isWin = platform() === 'win32';
224
+ const child = isWin
225
+ ? spawn('cmd.exe', ['/d', '/s', '/c', cmd], { detached: true, stdio: 'ignore', cwd })
226
+ : spawn('/bin/sh', ['-c', cmd], { detached: true, stdio: 'ignore', cwd });
227
+ child.unref();
228
+ return child.pid;
229
+ }
230
+
231
+ function killTree(pid) {
232
+ if (pid === null || pid === undefined) return;
233
+ try {
234
+ if (platform() === 'win32') {
235
+ spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
236
+ } else {
237
+ try {
238
+ process.kill(-pid, 'SIGTERM');
239
+ } catch {
240
+ process.kill(pid, 'SIGTERM');
241
+ }
242
+ }
243
+ } catch {
244
+ /* already gone */
245
+ }
246
+ }
247
+
248
+ function waitUntil(fn, deadlineMs) {
249
+ return new Promise((resolvePromise) => {
250
+ const tick = async () => {
251
+ try {
252
+ if (await fn()) return resolvePromise(true);
253
+ } catch {
254
+ /* keep waiting */
255
+ }
256
+ if (Date.now() > deadlineMs) return resolvePromise(false);
257
+ setTimeout(tick, 250);
258
+ };
259
+ tick();
260
+ });
261
+ }