@higherdev/cli 0.31.0 → 0.33.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.
@@ -15,8 +15,9 @@ export function formatAttachmentBytes(size) {
15
15
  return `${Math.round(size / 1024)} KB`;
16
16
  return `${(size / 1024 / 1024).toFixed(1)} MB`;
17
17
  }
18
- export function attachmentChip(file) {
19
- return `[${file.mime.startsWith("image/") ? "image" : "file"}: ${file.name} ${formatAttachmentBytes(file.size)}]`;
18
+ export function attachmentChip(file, percent) {
19
+ const progress = file.size > 1024 * 1024 && percent != null ? ` ${percent}%` : "";
20
+ return `[${file.mime.startsWith("image/") ? "image" : "file"}: ${file.name} ${formatAttachmentBytes(file.size)}${progress}]`;
20
21
  }
21
22
  function shellWords(text) {
22
23
  const words = [];
@@ -108,7 +109,7 @@ export async function detectDroppedPaths(pasted, isFile = async (path) => (await
108
109
  const checks = await Promise.all(words.map((path) => isFile(path).catch(() => false)));
109
110
  return checks.every(Boolean) ? words : [];
110
111
  }
111
- export async function uploadAttachment(path, target = {}, config = loadConfig()) {
112
+ export async function uploadAttachment(path, target = {}, config = loadConfig(), fetchImpl = fetch) {
112
113
  const info = await stat(path);
113
114
  if (!info.isFile())
114
115
  throw new Error(`${path} is not a file.`);
@@ -117,17 +118,49 @@ export async function uploadAttachment(path, target = {}, config = loadConfig())
117
118
  const mime = MIME[extname(path).toLowerCase()];
118
119
  if (!mime)
119
120
  throw new Error("Use an image, PDF, text, Markdown, JSON, or CSV file.");
120
- const form = new FormData();
121
- form.set("file", new Blob([await readFile(path)], { type: mime }), basename(path));
122
- if (target.ticketKey)
123
- form.set("ticket_key", target.ticketKey.toUpperCase());
124
- if (target.messageId)
125
- form.set("message_id", target.messageId);
126
- const response = await fetch(`${config.url}/api/w/${config.slug}/attachments`, {
127
- method: "POST", headers: { authorization: `Bearer ${config.api_key}` }, body: form,
121
+ const name = basename(path);
122
+ const bytes = await readFile(path);
123
+ const response = await fetchImpl(`${config.url}/api/w/${config.slug}/attachments/sign`, {
124
+ method: "POST", headers: { authorization: `Bearer ${config.api_key}`, "content-type": "application/json" },
125
+ body: JSON.stringify({ name, mime, size: info.size }),
128
126
  });
129
- const parsed = await response.json().catch(() => null);
130
- if (!response.ok || !parsed?.attachment)
131
- throw new Error(parsed?.error ?? `hd: ${response.status} Upload failed.`);
132
- return parsed.attachment;
127
+ const signed = await response.json().catch(() => null);
128
+ if (!response.ok || !signed?.attachment || !signed.signedUrl) {
129
+ throw new Error(signed?.error ?? `hd: ${response.status} Upload signing failed.`);
130
+ }
131
+ target.onProgress?.({ name, mime, size: info.size, percent: 0 });
132
+ const uploadResponse = await fetchImpl(signed.signedUrl, {
133
+ method: "PUT", headers: { "cache-control": "max-age=3600", "content-type": mime, "x-upsert": "false" },
134
+ body: progressStream(bytes, (percent) => target.onProgress?.({ name, mime, size: info.size, percent })),
135
+ duplex: "half",
136
+ });
137
+ const completeUrl = `${config.url}/api/w/${config.slug}/attachments/${signed.attachment.id}/complete`;
138
+ if (!uploadResponse.ok) {
139
+ await fetchImpl(completeUrl, { method: "POST", headers: { authorization: `Bearer ${config.api_key}` } }).catch(() => undefined);
140
+ throw new Error(`hd: ${uploadResponse.status} Direct attachment upload failed.`);
141
+ }
142
+ const completedResponse = await fetchImpl(completeUrl, {
143
+ method: "POST", headers: { authorization: `Bearer ${config.api_key}`, "content-type": "application/json" },
144
+ body: JSON.stringify({ ...(target.ticketKey ? { ticket_key: target.ticketKey.toUpperCase() } : {}),
145
+ ...(target.messageId ? { message_id: target.messageId } : {}) }),
146
+ });
147
+ const completed = await completedResponse.json().catch(() => null);
148
+ if (!completedResponse.ok || !completed?.attachment) {
149
+ throw new Error(completed?.error ?? `hd: ${completedResponse.status} Upload completion failed.`);
150
+ }
151
+ target.onProgress?.({ name, mime, size: info.size, percent: 100 });
152
+ return completed.attachment;
153
+ }
154
+ function progressStream(bytes, onProgress) {
155
+ let offset = 0;
156
+ return new ReadableStream({ pull(controller) {
157
+ if (offset >= bytes.byteLength) {
158
+ controller.close();
159
+ return;
160
+ }
161
+ const end = Math.min(offset + 64 * 1024, bytes.byteLength);
162
+ controller.enqueue(bytes.subarray(offset, end));
163
+ offset = end;
164
+ onProgress(bytes.byteLength ? Math.round(offset * 100 / bytes.byteLength) : 100);
165
+ } });
133
166
  }
package/dist/tui/App.js CHANGED
@@ -56,6 +56,7 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
56
56
  const [now, setNow] = useState(Date.now());
57
57
  const [draft, setDraft] = useState("");
58
58
  const [pendingAttachments, setPendingAttachments] = useState([]);
59
+ const [attachmentUploads, setAttachmentUploads] = useState({});
59
60
  const [busy, setBusy] = useState(false);
60
61
  const [notice, setNotice] = useState(null);
61
62
  const [availableUpdate, setAvailableUpdate] = useState(initialUpdate);
@@ -467,8 +468,18 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
467
468
  setBusy(true);
468
469
  try {
469
470
  const uploaded = [];
470
- for (const path of paths)
471
- uploaded.push(await uploadAttachment(path, {}, config));
471
+ for (const path of paths) {
472
+ try {
473
+ uploaded.push(await uploadAttachment(path, { onProgress: (progress) => setAttachmentUploads((current) => ({ ...current, [path]: progress })) }, config));
474
+ }
475
+ finally {
476
+ setAttachmentUploads((current) => {
477
+ const next = { ...current };
478
+ delete next[path];
479
+ return next;
480
+ });
481
+ }
482
+ }
472
483
  setPendingAttachments((current) => [...current, ...uploaded]);
473
484
  }
474
485
  catch (error) {
@@ -997,7 +1008,7 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
997
1008
  return _jsx(Bubble, { message: item.message, width: width }, item.key);
998
1009
  } }), splash ? (_jsx(Splash, { columns: columns, rows: rows, width: width, ready: ready, helpFull: plan.helpFull, animate: plan.fits, onDone: () => setReady(true) })) : null, _jsxs(Box, { flexDirection: "column", width: width, children: [view === "board" && plan.panels > 0 ? _jsx(BoardPanel, { board: board, width: width, rows: plan.panels, cursor: cursor }) : null, view === "roadmap" && plan.panels > 0 ? (_jsx(RoadmapPanel, { board: board, width: width, rows: plan.panels, offset: roadmapOffset })) : null, view === "agents" && plan.panels > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentsPanel, { board: board, width: width, rows: agentsView.top }), agentsView.bottom > 0 ? _jsxs(_Fragment, { children: [_jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: agentsView.bottom, live: running > 0 })] }) : null] })) : null, view === "feed" && plan.panels > 0 ? _jsx(FeedPanel, { entries: feed, width: width, rows: plan.panels }) : null, view === "settings" && plan.panels > 0 ? _jsx(SettingsPanel, { entries: settings, width: width, rows: plan.panels, title: "Settings", cursor: field, editing: editing }) : null, view === "inbox" && plan.panels > 0 ? _jsx(InboxPanel, { board: board, width: width, rows: plan.panels, focus: inboxFocus, selectedId: selectedDecisionId, answeringId: answering }) : null, view === "ticket" && plan.panels > 0 ? ticket
999
1010
  ? _jsx(TicketPanel, { ticket: ticket, width: width, rows: plan.panels, offset: ticketOffset, collapsed: ticketCollapsed })
1000
- : _jsxs(Text, { color: UI.warn, children: ["No ticket ", ticketKey, " here."] }) : null, view === "home" && plan.cockpit > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Cockpit, { board: board, width: width, rows: plan.cockpit, cursor: cursor }), _jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: plan.stream, live: running > 0 })] })) : null, chatting && plan.panels > 0 && activeThread ? (_jsx(ChatPanel, { thread: activeThread, width: width, rows: plan.panels, offset: chatOffset, label: chatLabel, now: now })) : null, chatting ? null : _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision, selectedId: selectedDecisionId }), notice ? _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: UI.warn, children: notice }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: UI.text, bold: true, children: workspace.slug }), _jsxs(Text, { color: UI.dim, children: [" ", workspace.repo, " "] }), _jsx(Text, { color: live === "live" ? UI.accent : UI.dim, children: "\u25CF " }), _jsxs(Text, { color: UI.dim, children: [live === "live" ? "5s poll" : live, " "] }), _jsx(Text, { color: UI.dim, children: running ? `${running} running ` : "" }), board.decisions.length ? _jsxs(Text, { color: UI.warn, children: [board.decisions.length, " decisions "] }) : null, workspace.paused ? _jsx(Text, { color: UI.warn, children: "paused " }) : null, logsFilter || rawLogs ? _jsxs(Text, { color: UI.warn, children: ["logs ", rawLogs ? "raw " : "", logsFilter ?? "all", " "] }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", chatting ? chatLabel : mode, answering ? " esc cancels" : chatting ? " ↑↓ scroll · pgup/pgdn · esc hides" : view === "ticket" ? " ↑↓ scroll · pgup/pgdn · enter folds" : view === "roadmap" ? " ↑↓ scroll · pgup/pgdn" : view === "inbox" ? " ↑↓ decisions · enter answers" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), availableUpdate || updateProgress ? (_jsx(Box, { children: _jsx(Text, { color: UI.warn, children: updateProgress ?? tuiUpdatePrompt(availableUpdate) }) })) : null, pendingAttachments.map((attachment) => (_jsx(Text, { color: UI.accent, children: attachmentChip(attachment) }, attachment.id))), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
1011
+ : _jsxs(Text, { color: UI.warn, children: ["No ticket ", ticketKey, " here."] }) : null, view === "home" && plan.cockpit > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Cockpit, { board: board, width: width, rows: plan.cockpit, cursor: cursor }), _jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: plan.stream, live: running > 0 })] })) : null, chatting && plan.panels > 0 && activeThread ? (_jsx(ChatPanel, { thread: activeThread, width: width, rows: plan.panels, offset: chatOffset, label: chatLabel, now: now })) : null, chatting ? null : _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision, selectedId: selectedDecisionId }), notice ? _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: UI.warn, children: notice }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: UI.text, bold: true, children: workspace.slug }), _jsxs(Text, { color: UI.dim, children: [" ", workspace.repo, " "] }), _jsx(Text, { color: live === "live" ? UI.accent : UI.dim, children: "\u25CF " }), _jsxs(Text, { color: UI.dim, children: [live === "live" ? "5s poll" : live, " "] }), _jsx(Text, { color: UI.dim, children: running ? `${running} running ` : "" }), board.decisions.length ? _jsxs(Text, { color: UI.warn, children: [board.decisions.length, " decisions "] }) : null, workspace.paused ? _jsx(Text, { color: UI.warn, children: "paused " }) : null, logsFilter || rawLogs ? _jsxs(Text, { color: UI.warn, children: ["logs ", rawLogs ? "raw " : "", logsFilter ?? "all", " "] }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", chatting ? chatLabel : mode, answering ? " esc cancels" : chatting ? " ↑↓ scroll · pgup/pgdn · esc hides" : view === "ticket" ? " ↑↓ scroll · pgup/pgdn · enter folds" : view === "roadmap" ? " ↑↓ scroll · pgup/pgdn" : view === "inbox" ? " ↑↓ decisions · enter answers" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), availableUpdate || updateProgress ? (_jsx(Box, { children: _jsx(Text, { color: UI.warn, children: updateProgress ?? tuiUpdatePrompt(availableUpdate) }) })) : null, pendingAttachments.map((attachment) => (_jsx(Text, { color: UI.accent, children: attachmentChip(attachment) }, attachment.id))), Object.entries(attachmentUploads).map(([path, upload]) => (_jsx(Text, { color: UI.accent, children: attachmentChip(upload, upload.percent) }, path))), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
1001
1012
  setDraft(next);
1002
1013
  if (editingRef.current)
1003
1014
  setEditing({ key: editingRef.current.key, draft: next });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",