@runwingman/flightdeck-cli 0.2.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,215 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ const VALID_STEP_TYPES = ['job_dispatch', 'approval'];
4
+
5
+ function normalizeArtifactsExpected(value) {
6
+ return Array.isArray(value)
7
+ ? value.map((item) => String(item || '').trim()).filter(Boolean)
8
+ : [];
9
+ }
10
+
11
+ function normalizeWhitelist(value) {
12
+ if (!Array.isArray(value)) return null;
13
+ const next = value.map((item) => String(item || '').trim()).filter(Boolean);
14
+ return next.length > 0 ? next : null;
15
+ }
16
+
17
+ function inferStepType(step) {
18
+ if (step?.type === 'job_dispatch' || step?.type === 'approval') return step.type;
19
+ if (!step) return 'job_dispatch';
20
+
21
+ const hasDispatchFields = [
22
+ step.job_type,
23
+ step.goals,
24
+ step.manager_guidance,
25
+ step.worker_guidance,
26
+ step.directory_override,
27
+ ].some((value) => String(value || '').trim() !== '');
28
+ if (hasDispatchFields) return 'job_dispatch';
29
+
30
+ const mode = step.approver_mode ?? step.approval_mode;
31
+ if (mode === 'manual' || mode === 'agent') return 'approval';
32
+ if (step.description || step.brief_template || step.whitelist_approvers || step.approver_whitelist) {
33
+ return 'approval';
34
+ }
35
+ return 'job_dispatch';
36
+ }
37
+
38
+ function normalizeWorkInstruction(step) {
39
+ return String(step?.instruction || step?.goals || step?.description || '').trim();
40
+ }
41
+
42
+ function normalizeApprovalDescription(step) {
43
+ return String(step?.description || step?.instruction || step?.goals || '').trim();
44
+ }
45
+
46
+ export function canonicalizeFlowStep(step) {
47
+ if (!step) return step;
48
+
49
+ const type = inferStepType(step);
50
+ if (type === 'approval') {
51
+ return {
52
+ step_number: step.step_number,
53
+ title: step.title ?? '',
54
+ type: 'approval',
55
+ description: normalizeApprovalDescription(step),
56
+ brief_template: String(step.brief_template || '').trim(),
57
+ approver_mode: (step.approver_mode ?? step.approval_mode) === 'agent' ? 'agent' : 'manual',
58
+ whitelist_approvers: normalizeWhitelist(step.whitelist_approvers ?? step.approver_whitelist),
59
+ artifacts_expected: normalizeArtifactsExpected(step.artifacts_expected),
60
+ };
61
+ }
62
+
63
+ return {
64
+ step_number: step.step_number,
65
+ title: step.title ?? '',
66
+ type: 'job_dispatch',
67
+ instruction: normalizeWorkInstruction(step),
68
+ artifacts_expected: normalizeArtifactsExpected(step.artifacts_expected),
69
+ };
70
+ }
71
+
72
+ export function canonicalizeFlowSteps(steps) {
73
+ return Array.isArray(steps) ? steps.map((step) => canonicalizeFlowStep(step)) : [];
74
+ }
75
+
76
+ /**
77
+ * Validates an array of flow step objects.
78
+ * Each step must have a step_number (positive integer) and type ('job_dispatch' or 'approval').
79
+ */
80
+ export function validateFlowSteps(steps) {
81
+ const errors = [];
82
+ if (!Array.isArray(steps)) {
83
+ return { valid: false, errors: ['steps must be an array'] };
84
+ }
85
+ for (let i = 0; i < steps.length; i++) {
86
+ const step = steps[i];
87
+ const label = `step[${i}]`;
88
+ if (!Number.isFinite(step.step_number) || step.step_number < 1) {
89
+ errors.push(`${label}: step_number must be a positive integer`);
90
+ }
91
+ if (!step.type) {
92
+ errors.push(`${label}: type is required (job_dispatch or approval)`);
93
+ } else if (!VALID_STEP_TYPES.includes(step.type)) {
94
+ errors.push(`${label}: unknown type "${step.type}" (must be job_dispatch or approval)`);
95
+ }
96
+ }
97
+ return { valid: errors.length === 0, errors };
98
+ }
99
+
100
+ /**
101
+ * Builds an approval record payload from a flow's approval-type step.
102
+ */
103
+ export function buildApprovalForStep(flow, step, runContext) {
104
+ const normalized = canonicalizeFlowStep(step);
105
+ return {
106
+ record_id: crypto.randomUUID(),
107
+ owner_npub: runContext.ownerNpub,
108
+ title: normalized.title ?? '',
109
+ description: normalized.description ?? '',
110
+ brief: normalized.brief_template || normalized.description || '',
111
+ flow_id: flow.record_id,
112
+ flow_run_id: runContext.flowRunId,
113
+ flow_step: normalized.step_number,
114
+ task_ids: runContext.taskIds ?? [],
115
+ status: 'pending',
116
+ approval_mode: normalized.approver_mode ?? 'manual',
117
+ approver_whitelist: normalized.whitelist_approvers ?? [],
118
+ confidence_score: null,
119
+ approved_by: null,
120
+ approved_at: null,
121
+ decision_note: null,
122
+ agent_review_by: null,
123
+ agent_review_note: null,
124
+ artifact_refs: runContext.artifactRefs ?? [],
125
+ revision_task_id: null,
126
+ scope_id: flow.scope_id ?? null,
127
+ scope_l1_id: flow.scope_l1_id ?? null,
128
+ scope_l2_id: flow.scope_l2_id ?? null,
129
+ scope_l3_id: flow.scope_l3_id ?? null,
130
+ scope_l4_id: flow.scope_l4_id ?? null,
131
+ scope_l5_id: flow.scope_l5_id ?? null,
132
+ shares: runContext.shares ?? [],
133
+ group_ids: runContext.groupIds ?? [],
134
+ record_state: 'active',
135
+ version: 0,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Builds a task record payload from a flow's job_dispatch-type step.
141
+ */
142
+ export function buildTaskForStep(flow, step, runContext) {
143
+ const normalized = canonicalizeFlowStep(step);
144
+ return {
145
+ record_id: crypto.randomUUID(),
146
+ owner_npub: runContext.ownerNpub,
147
+ title: normalized.title ?? '',
148
+ description: normalized.instruction ?? '',
149
+ state: 'ready',
150
+ priority: 'sand',
151
+ flow_id: flow.record_id,
152
+ flow_run_id: runContext.flowRunId,
153
+ flow_step: normalized.step_number,
154
+ predecessor_task_ids: runContext.predecessorTaskIds ?? [],
155
+ scope_id: flow.scope_id ?? null,
156
+ scope_l1_id: flow.scope_l1_id ?? null,
157
+ scope_l2_id: flow.scope_l2_id ?? null,
158
+ scope_l3_id: flow.scope_l3_id ?? null,
159
+ scope_l4_id: flow.scope_l4_id ?? null,
160
+ scope_l5_id: flow.scope_l5_id ?? null,
161
+ shares: runContext.shares ?? [],
162
+ group_ids: runContext.groupIds ?? [],
163
+ record_state: 'active',
164
+ version: 0,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Starts a flow run: generates a flow_run_id and returns the first step.
170
+ */
171
+ export function startFlowRun(flow) {
172
+ const steps = flow.steps ?? [];
173
+ if (steps.length === 0) {
174
+ throw new Error(`Flow "${flow.title ?? flow.record_id}" has no steps`);
175
+ }
176
+ const sorted = canonicalizeFlowSteps(steps).sort((a, b) => a.step_number - b.step_number);
177
+ return {
178
+ flow_id: flow.record_id,
179
+ flow_run_id: crypto.randomUUID(),
180
+ current_step: sorted[0].step_number,
181
+ first_step: sorted[0],
182
+ total_steps: steps.length,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Executes a specific flow step by dispatching to the correct builder.
188
+ * Returns { type, task, approval } where one of task/approval is populated.
189
+ */
190
+ export function executeFlowStep(flow, stepNumber, runContext) {
191
+ const steps = flow.steps ?? [];
192
+ const rawStep = steps.find((s) => s.step_number === stepNumber);
193
+ const step = canonicalizeFlowStep(rawStep);
194
+ if (!step) {
195
+ throw new Error(`Step ${stepNumber} not found in flow "${flow.title ?? flow.record_id}"`);
196
+ }
197
+
198
+ if (step.type === 'job_dispatch') {
199
+ return {
200
+ type: 'job_dispatch',
201
+ task: buildTaskForStep(flow, step, runContext),
202
+ approval: null,
203
+ };
204
+ }
205
+
206
+ if (step.type === 'approval') {
207
+ return {
208
+ type: 'approval',
209
+ task: null,
210
+ approval: buildApprovalForStep(flow, step, runContext),
211
+ };
212
+ }
213
+
214
+ throw new Error(`Unknown step type "${step.type}" at step ${stepNumber}`);
215
+ }
package/src/nostr.js ADDED
@@ -0,0 +1,160 @@
1
+ import { createHash } from 'node:crypto';
2
+ import * as childProcess from 'node:child_process';
3
+ import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import * as os from 'node:os';
6
+ import { finalizeEvent, generateSecretKey, getPublicKey, nip19, nip44 } from 'nostr-tools';
7
+
8
+ function hexToBytes(hex) {
9
+ const normalized = String(hex || '').trim().toLowerCase();
10
+ if (!/^[0-9a-f]{64}$/.test(normalized)) throw new Error('Invalid secret hex.');
11
+ const bytes = new Uint8Array(32);
12
+ for (let i = 0; i < normalized.length; i += 2) {
13
+ bytes[i / 2] = Number.parseInt(normalized.slice(i, i + 2), 16);
14
+ }
15
+ return bytes;
16
+ }
17
+
18
+ function loadNsecFromBitwarden() {
19
+ const sessionPath = path.join(os.homedir(), '.bw_session');
20
+ if (!fs.existsSync(sessionPath)) return null;
21
+ const bwSession = fs.readFileSync(sessionPath, 'utf8').trim();
22
+ return childProcess.execFileSync('bw', ['get', 'password', 'wm21-nostr'], {
23
+ env: { ...process.env, BW_SESSION: bwSession },
24
+ encoding: 'utf8',
25
+ stdio: ['ignore', 'pipe', 'pipe'],
26
+ }).trim();
27
+ }
28
+
29
+ function loadSecretFromWingmen() {
30
+ const wingmanUrl = String(process.env.WINGMAN_URL || '').trim();
31
+ const sessionId = String(process.env.SESSION_ID || '').trim();
32
+ if (!wingmanUrl || !sessionId) return null;
33
+
34
+ try {
35
+ const url = new URL('/api/bot-keys/export-nsec', wingmanUrl).toString();
36
+ const payload = JSON.stringify({ sessionId });
37
+ const script = `
38
+ const url = ${JSON.stringify(url)};
39
+ const payload = ${JSON.stringify(payload)};
40
+ const response = await fetch(url, {
41
+ method: 'POST',
42
+ headers: {
43
+ 'content-type': 'application/json',
44
+ 'connection': 'close',
45
+ },
46
+ body: payload,
47
+ });
48
+ if (!response.ok) {
49
+ const text = await response.text().catch(() => '');
50
+ throw new Error(text || response.statusText || String(response.status));
51
+ }
52
+ const data = await response.json();
53
+ if (!data?.nsec || typeof data.nsec !== 'string') {
54
+ throw new Error('Missing nsec in response');
55
+ }
56
+ process.stdout.write(data.nsec.trim());
57
+ process.exit(0);
58
+ `;
59
+ const output = childProcess.execFileSync(process.execPath, ['--input-type=module', '-e', script], {
60
+ encoding: 'utf8',
61
+ stdio: ['ignore', 'pipe', 'pipe'],
62
+ }).trim();
63
+ return output || null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export function getConfiguredNsec() {
70
+ return process.env.FLIGHTDECK_CLI_NSEC
71
+ || process.env.WINGMAN_YOKE_NSEC
72
+ || process.env.AGENT_NSEC
73
+ || loadSecretFromWingmen()
74
+ || process.env.WINGMAN_AUTOPILOT_NSEC
75
+ || process.env.NOSTR_NSEC
76
+ || loadNsecFromBitwarden();
77
+ }
78
+
79
+ export function decodeNsec(nsec) {
80
+ const decoded = nip19.decode(String(nsec).trim());
81
+ if (decoded.type !== 'nsec') throw new Error('Invalid nsec.');
82
+ return decoded.data;
83
+ }
84
+
85
+ export function decodeSecret(secret) {
86
+ const value = String(secret || '').trim();
87
+ if (!value) throw new Error('Missing secret.');
88
+ if (/^[0-9a-fA-F]{64}$/.test(value)) return hexToBytes(value);
89
+ return decodeNsec(value);
90
+ }
91
+
92
+ export function decodeNpub(npub) {
93
+ const decoded = nip19.decode(String(npub).trim());
94
+ if (decoded.type !== 'npub') throw new Error(`Invalid npub: ${npub}`);
95
+ return decoded.data;
96
+ }
97
+
98
+ export function encodeNsec(secret) {
99
+ return nip19.nsecEncode(secret);
100
+ }
101
+
102
+ export function createGroupIdentity(secret = generateSecretKey()) {
103
+ const pubkey = getPublicKey(secret);
104
+ return {
105
+ secret,
106
+ pubkey,
107
+ npub: nip19.npubEncode(pubkey),
108
+ nsec: encodeNsec(secret),
109
+ };
110
+ }
111
+
112
+ export function buildWrappedMemberKeys(groupIdentity, memberNpubs, wrappedByNpub, wrappedBySecret) {
113
+ const uniqueMembers = [...new Set((memberNpubs || []).map((value) => String(value || '').trim()).filter(Boolean))];
114
+ return uniqueMembers.map((member_npub) => ({
115
+ member_npub,
116
+ wrapped_group_nsec: encryptForNpub(wrappedBySecret, member_npub, groupIdentity.nsec),
117
+ wrapped_by_npub: wrappedByNpub,
118
+ }));
119
+ }
120
+
121
+ export function getSession(secret = decodeSecret(getConfiguredNsec())) {
122
+ const pubkey = getPublicKey(secret);
123
+ return {
124
+ secret,
125
+ pubkey,
126
+ npub: nip19.npubEncode(pubkey),
127
+ };
128
+ }
129
+
130
+ function sha256Hex(input) {
131
+ return createHash('sha256').update(input, 'utf8').digest('hex');
132
+ }
133
+
134
+ export function createNip98AuthHeader(url, method, body, secret) {
135
+ const tags = [
136
+ ['u', url],
137
+ ['method', method.toUpperCase()],
138
+ ];
139
+ if (body != null && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
140
+ const serialized = typeof body === 'string' ? body : JSON.stringify(body);
141
+ tags.push(['payload', sha256Hex(serialized)]);
142
+ }
143
+ const event = finalizeEvent({
144
+ kind: 27235,
145
+ created_at: Math.floor(Date.now() / 1000),
146
+ tags,
147
+ content: '',
148
+ }, secret);
149
+ return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
150
+ }
151
+
152
+ export function encryptForNpub(secret, recipientNpub, plaintext) {
153
+ const conversationKey = nip44.v2.utils.getConversationKey(secret, decodeNpub(recipientNpub));
154
+ return nip44.v2.encrypt(plaintext, conversationKey);
155
+ }
156
+
157
+ export function decryptFromNpub(secret, senderNpub, ciphertext) {
158
+ const conversationKey = nip44.v2.utils.getConversationKey(secret, decodeNpub(senderNpub));
159
+ return nip44.v2.decrypt(ciphertext, conversationKey);
160
+ }
package/src/render.js ADDED
@@ -0,0 +1,34 @@
1
+ const STORAGE_LINK_RE = /storage:\/\/([a-f0-9-]+)/gi;
2
+
3
+ export function extractStorageObjectIds(text) {
4
+ const ids = new Set();
5
+ for (const match of String(text || '').matchAll(STORAGE_LINK_RE)) {
6
+ if (match[1]) ids.add(match[1]);
7
+ }
8
+ return [...ids];
9
+ }
10
+
11
+ export async function resolveStorageLinks(client, text) {
12
+ const objectIds = extractStorageObjectIds(text);
13
+ const resolved = [];
14
+ for (const objectId of objectIds) {
15
+ try {
16
+ const storageObject = await client.getStorageObject(objectId);
17
+ resolved.push({
18
+ object_id: objectId,
19
+ file_name: storageObject.file_name || null,
20
+ content_type: storageObject.content_type || null,
21
+ size_bytes: storageObject.size_bytes ?? null,
22
+ content_url: storageObject.content_url || client.getStorageContentUrl(objectId),
23
+ download_url: storageObject.download_url || null,
24
+ completed_at: storageObject.completed_at || null,
25
+ });
26
+ } catch (error) {
27
+ resolved.push({
28
+ object_id: objectId,
29
+ error: error instanceof Error ? error.message : String(error),
30
+ });
31
+ }
32
+ }
33
+ return resolved;
34
+ }
@@ -0,0 +1,17 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ const require = createRequire(import.meta.url);
4
+
5
+ export function assertNodeRuntime() {
6
+ if (typeof Bun !== 'undefined') {
7
+ throw new Error(
8
+ 'FlightDeck CLI currently supports Node.js only. Run it with `node src/cli.js` in a local clone, or use the installed `flightdeck-cli` binary.',
9
+ );
10
+ }
11
+ }
12
+
13
+ export function loadBetterSqlite3() {
14
+ assertNodeRuntime();
15
+ const mod = require('better-sqlite3');
16
+ return mod?.default ?? mod;
17
+ }
package/src/storage.js ADDED
@@ -0,0 +1,191 @@
1
+ import { basename, extname } from 'node:path';
2
+ import { createHash, webcrypto } from 'node:crypto';
3
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { tmpdir } from 'node:os';
6
+
7
+ const MIME_BY_EXT = new Map([
8
+ ['.png', 'image/png'],
9
+ ['.jpg', 'image/jpeg'],
10
+ ['.jpeg', 'image/jpeg'],
11
+ ['.gif', 'image/gif'],
12
+ ['.webp', 'image/webp'],
13
+ ['.svg', 'image/svg+xml'],
14
+ ['.mp3', 'audio/mpeg'],
15
+ ['.wav', 'audio/wav'],
16
+ ['.m4a', 'audio/mp4'],
17
+ ['.aac', 'audio/aac'],
18
+ ['.ogg', 'audio/ogg'],
19
+ ['.oga', 'audio/ogg'],
20
+ ['.opus', 'audio/ogg'],
21
+ ['.webm', 'audio/webm'],
22
+ ['.aif', 'audio/aiff'],
23
+ ['.aiff', 'audio/aiff'],
24
+ ['.txt', 'text/plain'],
25
+ ['.md', 'text/markdown'],
26
+ ['.json', 'application/json'],
27
+ ['.pdf', 'application/pdf'],
28
+ ]);
29
+
30
+ const BROWSER_FRIENDLY_AUDIO = new Set([
31
+ 'audio/webm',
32
+ 'audio/webm;codecs=opus',
33
+ 'audio/ogg',
34
+ 'audio/mp4',
35
+ 'audio/mpeg',
36
+ 'audio/wav',
37
+ ]);
38
+
39
+ function bytesToBase64(bytes) {
40
+ return Buffer.from(bytes).toString('base64');
41
+ }
42
+
43
+ export function detectMimeType(filePath, fallback = 'application/octet-stream') {
44
+ const ext = extname(String(filePath || '')).toLowerCase();
45
+ return MIME_BY_EXT.get(ext) || fallback;
46
+ }
47
+
48
+ export function readFileBytes(filePath) {
49
+ return new Uint8Array(readFileSync(filePath));
50
+ }
51
+
52
+ export function sha256Hex(bytes) {
53
+ return createHash('sha256').update(bytes).digest('hex');
54
+ }
55
+
56
+ export function defaultFileName(filePath, fallbackPrefix = 'upload') {
57
+ const base = basename(String(filePath || '').trim());
58
+ if (base) return base;
59
+ return `${fallbackPrefix}.bin`;
60
+ }
61
+
62
+ export function createStorageMarkdown(objectId, fileName = 'image') {
63
+ const safeAlt = String(fileName || 'image').replace(/[\]\[]/g, '').trim() || 'image';
64
+ return `![${safeAlt}](storage://${objectId})`;
65
+ }
66
+
67
+ export async function uploadFileToStorage(client, filePath, {
68
+ ownerNpub,
69
+ accessGroupIds = [],
70
+ ownerGroupId = null,
71
+ contentType = null,
72
+ fileName = null,
73
+ } = {}) {
74
+ const bytes = readFileBytes(filePath);
75
+ const nextFileName = fileName || defaultFileName(filePath, 'upload');
76
+ const nextContentType = contentType || detectMimeType(filePath);
77
+ const body = {
78
+ owner_npub: ownerNpub,
79
+ content_type: nextContentType,
80
+ size_bytes: bytes.byteLength,
81
+ file_name: nextFileName,
82
+ access_group_ids: accessGroupIds,
83
+ };
84
+ const resolvedOwnerGroupId = ownerGroupId || accessGroupIds[0] || null;
85
+ if (resolvedOwnerGroupId) body.owner_group_id = resolvedOwnerGroupId;
86
+ const prepared = await client.prepareStorageObject(body);
87
+ await client.uploadPreparedStorageObject(prepared, bytes, nextContentType);
88
+ await client.completeStorageObject(prepared.object_id, {
89
+ size_bytes: bytes.byteLength,
90
+ sha256_hex: sha256Hex(bytes),
91
+ });
92
+ return {
93
+ ...prepared,
94
+ object_id: prepared.object_id,
95
+ size_bytes: bytes.byteLength,
96
+ content_type: nextContentType,
97
+ file_name: nextFileName,
98
+ sha256_hex: sha256Hex(bytes),
99
+ };
100
+ }
101
+
102
+ export async function encryptAudioBytes(bytes) {
103
+ const key = await webcrypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
104
+ const iv = webcrypto.getRandomValues(new Uint8Array(12));
105
+ const ciphertext = await webcrypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, bytes);
106
+ const rawKey = new Uint8Array(await webcrypto.subtle.exportKey('raw', key));
107
+ return {
108
+ encryptedBytes: new Uint8Array(ciphertext),
109
+ mediaEncryption: {
110
+ scheme: 'aes-gcm',
111
+ key_b64: bytesToBase64(rawKey),
112
+ iv_b64: bytesToBase64(iv),
113
+ },
114
+ };
115
+ }
116
+
117
+ export async function uploadEncryptedAudioToStorage(client, filePath, {
118
+ ownerNpub,
119
+ accessGroupIds = [],
120
+ ownerGroupId = null,
121
+ contentType = null,
122
+ fileName = null,
123
+ } = {}) {
124
+ const normalized = normalizeAudioInput(filePath, contentType);
125
+ try {
126
+ const plainBytes = readFileBytes(normalized.filePath);
127
+ const encrypted = await encryptAudioBytes(plainBytes);
128
+ const nextFileName = fileName || normalized.fileName || defaultFileName(normalized.filePath, 'voice-note');
129
+ const nextContentType = normalized.contentType || detectMimeType(normalized.filePath, 'audio/webm');
130
+ const body = {
131
+ owner_npub: ownerNpub,
132
+ content_type: nextContentType,
133
+ size_bytes: encrypted.encryptedBytes.byteLength,
134
+ file_name: nextFileName,
135
+ access_group_ids: accessGroupIds,
136
+ };
137
+ const resolvedOwnerGroupId = ownerGroupId || accessGroupIds[0] || null;
138
+ if (resolvedOwnerGroupId) body.owner_group_id = resolvedOwnerGroupId;
139
+ const prepared = await client.prepareStorageObject(body);
140
+ await client.uploadPreparedStorageObject(prepared, encrypted.encryptedBytes, nextContentType);
141
+ await client.completeStorageObject(prepared.object_id, {
142
+ size_bytes: encrypted.encryptedBytes.byteLength,
143
+ sha256_hex: sha256Hex(encrypted.encryptedBytes),
144
+ });
145
+ return {
146
+ ...prepared,
147
+ object_id: prepared.object_id,
148
+ size_bytes: encrypted.encryptedBytes.byteLength,
149
+ content_type: nextContentType,
150
+ file_name: nextFileName,
151
+ sha256_hex: sha256Hex(encrypted.encryptedBytes),
152
+ media_encryption: encrypted.mediaEncryption,
153
+ };
154
+ } finally {
155
+ normalized.cleanup();
156
+ }
157
+ }
158
+
159
+ function normalizeAudioInput(filePath, providedContentType = null) {
160
+ const nextContentType = providedContentType || detectMimeType(filePath, 'audio/webm');
161
+ const nextFileName = defaultFileName(filePath, 'voice-note');
162
+ if (BROWSER_FRIENDLY_AUDIO.has(nextContentType)) {
163
+ return {
164
+ filePath,
165
+ contentType: nextContentType,
166
+ fileName: nextFileName,
167
+ cleanup() {},
168
+ };
169
+ }
170
+ if (nextContentType === 'audio/aiff') {
171
+ const tmpDir = mkdtempSync(`${tmpdir()}/wm-ap-audio-`);
172
+ const wavPath = `${tmpDir}/${basename(filePath, extname(filePath))}.wav`;
173
+ execFileSync('afconvert', ['-f', 'WAVE', '-d', 'LEI16@44100', filePath, wavPath], {
174
+ stdio: ['ignore', 'ignore', 'pipe'],
175
+ });
176
+ return {
177
+ filePath: wavPath,
178
+ contentType: 'audio/wav',
179
+ fileName: `${basename(filePath, extname(filePath))}.wav`,
180
+ cleanup() {
181
+ rmSync(tmpDir, { recursive: true, force: true });
182
+ },
183
+ };
184
+ }
185
+ return {
186
+ filePath,
187
+ contentType: nextContentType,
188
+ fileName: nextFileName,
189
+ cleanup() {},
190
+ };
191
+ }