@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.
package/src/client.js ADDED
@@ -0,0 +1,285 @@
1
+ import { createNip98AuthHeader } from './nostr.js';
2
+ import {
3
+ RecordManager,
4
+ buildLegacyWriteGroupFields,
5
+ } from '@nostr-superbased/core/records';
6
+
7
+ function getCheckoutPolicyConfig(config = {}, options = {}) {
8
+ return options.checkoutPolicy
9
+ || config.recordCheckoutPolicy
10
+ || config.record_checkout_policy
11
+ || config.checkoutPolicy
12
+ || null;
13
+ }
14
+
15
+ export class SuperbasedClient {
16
+ constructor({ config, session, groupKeys }) {
17
+ this.config = config;
18
+ this.session = session;
19
+ this.groupKeys = groupKeys;
20
+ this._authSecret = null;
21
+ }
22
+
23
+ setAuthSecret(secret) {
24
+ this._authSecret = secret;
25
+ }
26
+
27
+ get authSecret() {
28
+ return this._authSecret ?? this.session.secret;
29
+ }
30
+
31
+ url(pathname) {
32
+ return new URL(pathname, this.config.directHttpsUrl).toString();
33
+ }
34
+
35
+ async requestRaw(pathname, { method = 'GET', body = null, authHeader = null, headers = {} } = {}) {
36
+ const url = this.url(pathname);
37
+ const authorization = authHeader || createNip98AuthHeader(url, method, body, this.authSecret);
38
+ return fetch(url, {
39
+ method,
40
+ headers: {
41
+ Authorization: authorization,
42
+ ...(body != null ? { 'Content-Type': 'application/json' } : {}),
43
+ ...headers,
44
+ },
45
+ body: body != null ? JSON.stringify(body) : undefined,
46
+ });
47
+ }
48
+
49
+ async request(pathname, options = {}) {
50
+ const response = await this.requestRaw(pathname, options);
51
+ if (!response.ok) {
52
+ const text = await response.text().catch(() => '');
53
+ let payload = null;
54
+ try {
55
+ payload = text ? JSON.parse(text) : null;
56
+ } catch {
57
+ payload = null;
58
+ }
59
+ const code = typeof payload?.code === 'string' ? payload.code : null;
60
+ if (code === 'checkout_required' || code === 'checkout_missing') {
61
+ throw new Error(`API ${response.status} (${code}): this record requires checkout before editing. Sync first, acquire checkout in the app, then retry the write.`);
62
+ }
63
+ if (code === 'edit_policy_forbidden') {
64
+ throw new Error(`API ${response.status} (${code}): this record is not editable by the current user or group.`);
65
+ }
66
+ throw new Error(`API ${response.status}${code ? ` (${code})` : ''}: ${text}`);
67
+ }
68
+ return response.json();
69
+ }
70
+
71
+ registerWorkspaceKey(body) {
72
+ return this.request('/api/v4/user/workspace-keys', {
73
+ method: 'POST',
74
+ body,
75
+ });
76
+ }
77
+
78
+ listWorkspaceKeys() {
79
+ return this.request('/api/v4/user/workspace-keys');
80
+ }
81
+
82
+ fetchWorkspaceKeyMappings(workspaceOwnerNpub) {
83
+ return this.request(`/api/v4/user/workspace-key-mappings?workspace_owner_npub=${encodeURIComponent(workspaceOwnerNpub)}`);
84
+ }
85
+
86
+ fetchWorkspaceAppSchemas({ appNpub = null, latest = true } = {}) {
87
+ const params = new URLSearchParams();
88
+ if (appNpub) params.set('app_npub', appNpub);
89
+ if (latest !== undefined) params.set('latest', latest ? 'true' : 'false');
90
+ const qs = params.toString();
91
+ return this.request(`/api/v4/workspaces/${encodeURIComponent(this.config.workspaceOwnerNpub)}/app-schemas${qs ? `?${qs}` : ''}`);
92
+ }
93
+
94
+ getGroups() {
95
+ return this.request(`/api/v4/groups?npub=${encodeURIComponent(this.session.npub)}`);
96
+ }
97
+
98
+ getGroupKeys() {
99
+ return this.request(`/api/v4/groups/keys?member_npub=${encodeURIComponent(this.session.npub)}`);
100
+ }
101
+
102
+ rotateGroup(groupId, body) {
103
+ return this.request(`/api/v4/groups/${encodeURIComponent(groupId)}/rotate`, {
104
+ method: 'POST',
105
+ body,
106
+ });
107
+ }
108
+
109
+ async fetchRecords(recordFamilyHash, since = null) {
110
+ const PAGE_SIZE = 200;
111
+ let offset = 0;
112
+ let allRecords = [];
113
+
114
+ while (true) {
115
+ const params = new URLSearchParams({
116
+ owner_npub: this.config.workspaceOwnerNpub,
117
+ viewer_npub: this.session.npub,
118
+ record_family_hash: recordFamilyHash,
119
+ limit: String(PAGE_SIZE),
120
+ offset: String(offset),
121
+ });
122
+ if (since) params.set('since', since);
123
+ const page = await this.request(`/api/v4/records?${params.toString()}`);
124
+ const records = page.records ?? [];
125
+ allRecords = allRecords.concat(records);
126
+ if (!page.has_more || records.length === 0) break;
127
+ offset += records.length;
128
+ }
129
+
130
+ return { records: allRecords };
131
+ }
132
+
133
+ getRecordHistory(recordId) {
134
+ const params = new URLSearchParams({
135
+ owner_npub: this.config.workspaceOwnerNpub,
136
+ viewer_npub: this.session.npub,
137
+ });
138
+ return this.request(`/api/v4/records/${encodeURIComponent(recordId)}/history?${params.toString()}`);
139
+ }
140
+
141
+ async syncRecords(records, options = {}) {
142
+ const normalizedRecords = (records || []).map((record) => {
143
+ const normalized = {
144
+ ...record,
145
+ owner_npub: record.owner_npub || this.config.workspaceOwnerNpub,
146
+ workspace_service_npub: record.workspace_service_npub || record.owner_npub || this.config.workspaceOwnerNpub,
147
+ user_npub: record.user_npub || this.session.userNpub || this.session.npub,
148
+ };
149
+ const workspaceUserKeyNpub = String(record.workspace_user_key_npub || '').trim()
150
+ || (record.signature_npub && record.signature_npub !== this.session.npub ? record.signature_npub : '');
151
+ if (workspaceUserKeyNpub) {
152
+ normalized.workspace_user_key_npub = workspaceUserKeyNpub;
153
+ }
154
+
155
+ delete normalized.write_group_id;
156
+ delete normalized.write_group_npub;
157
+ Object.assign(normalized, buildLegacyWriteGroupFields({
158
+ writeGroupId: record?.write_group_id,
159
+ writeGroupRef: record?.write_group_npub,
160
+ }));
161
+ return normalized;
162
+ });
163
+ if (normalizedRecords.length === 0) {
164
+ const workspaceServiceNpub = this.config.workspaceServiceNpub || this.config.workspaceOwnerNpub;
165
+ const userNpub = this.session.userNpub || this.session.npub;
166
+ return this.request('/api/v4/records/sync', {
167
+ method: 'POST',
168
+ body: {
169
+ owner_npub: workspaceServiceNpub,
170
+ workspace_service_npub: workspaceServiceNpub,
171
+ user_npub: userNpub,
172
+ actor_npub: userNpub,
173
+ viewer_npub: userNpub,
174
+ records: [],
175
+ group_write_tokens: {},
176
+ },
177
+ });
178
+ }
179
+ const workspaceUserKeyNpubs = [...new Set(normalizedRecords
180
+ .map((record) => String(record.workspace_user_key_npub || '').trim())
181
+ .filter(Boolean))];
182
+ const workspaceUserKeyNpub = workspaceUserKeyNpubs.length === 1 ? workspaceUserKeyNpubs[0] : null;
183
+ const identityContext = {
184
+ workspaceServiceNpub: this.config.workspaceServiceNpub || this.config.workspaceOwnerNpub,
185
+ userNpub: this.session.userNpub || this.session.npub,
186
+ ...(workspaceUserKeyNpub ? {
187
+ workspaceUserKeyNpub,
188
+ signerNpub: workspaceUserKeyNpub,
189
+ } : {}),
190
+ };
191
+ const recordManager = new RecordManager({
192
+ buildUrl: (pathname) => this.url(pathname),
193
+ getIdentityContext: () => identityContext,
194
+ getGroupKey: (groupRef) => (
195
+ this.groupKeys.getCurrent
196
+ ? this.groupKeys.getCurrent(groupRef)
197
+ : this.groupKeys.get(groupRef)
198
+ ),
199
+ });
200
+ const syncRequest = await recordManager.buildCheckoutAwareSyncRequest({
201
+ records: normalizedRecords,
202
+ identityContext,
203
+ checkout: options.checkout,
204
+ checkoutsByRecordId: options.checkoutsByRecordId,
205
+ checkoutPolicy: getCheckoutPolicyConfig(this.config, options) || undefined,
206
+ });
207
+ return this.request('/api/v4/records/sync', {
208
+ method: 'POST',
209
+ body: {
210
+ owner_npub: syncRequest.owner_npub,
211
+ workspace_service_npub: syncRequest.workspace_service_npub,
212
+ ...(syncRequest.user_npub ? { user_npub: syncRequest.user_npub } : {}),
213
+ ...(syncRequest.actor_npub ? { actor_npub: syncRequest.actor_npub } : {}),
214
+ ...(syncRequest.viewer_npub ? { viewer_npub: syncRequest.viewer_npub } : {}),
215
+ ...(syncRequest.signer_npub ? { signer_npub: syncRequest.signer_npub } : {}),
216
+ ...(syncRequest.workspace_user_key_npub ? { workspace_user_key_npub: syncRequest.workspace_user_key_npub } : {}),
217
+ ...(syncRequest.ws_key_npub ? { ws_key_npub: syncRequest.ws_key_npub } : {}),
218
+ records: syncRequest.records,
219
+ group_write_tokens: syncRequest.group_write_tokens,
220
+ },
221
+ });
222
+ }
223
+
224
+ getStorageDownloadUrl(objectId) {
225
+ return this.request(`/api/v4/storage/${objectId}/download-url`);
226
+ }
227
+
228
+ getStorageObject(objectId) {
229
+ return this.request(`/api/v4/storage/${objectId}`);
230
+ }
231
+
232
+ getStorageContentUrl(objectId) {
233
+ return this.url(`/api/v4/storage/${objectId}/content`);
234
+ }
235
+
236
+ async getStorageContent(objectId) {
237
+ const response = await this.requestRaw(`/api/v4/storage/${objectId}/content`);
238
+ if (!response.ok) {
239
+ const text = await response.text().catch(() => '');
240
+ throw new Error(`API ${response.status}: ${text}`);
241
+ }
242
+ return new Uint8Array(await response.arrayBuffer());
243
+ }
244
+
245
+ prepareStorageObject(body) {
246
+ return this.request('/api/v4/storage/prepare', {
247
+ method: 'POST',
248
+ body,
249
+ });
250
+ }
251
+
252
+ async uploadPreparedStorageObject(prepared, bytes, contentType = 'application/octet-stream') {
253
+ const uploadUrl = String(prepared?.upload_url || '').trim();
254
+ if (!uploadUrl) {
255
+ throw new Error('Missing upload URL for storage object.');
256
+ }
257
+ const directResponse = await fetch(uploadUrl, {
258
+ method: 'PUT',
259
+ headers: {
260
+ 'Content-Type': contentType,
261
+ },
262
+ body: bytes,
263
+ });
264
+ if (directResponse.ok) {
265
+ return {
266
+ object_id: prepared.object_id,
267
+ size_bytes: bytes.byteLength,
268
+ content_type: contentType,
269
+ };
270
+ }
271
+ return this.request(`/api/v4/storage/${prepared.object_id}`, {
272
+ method: 'PUT',
273
+ body: {
274
+ base64_data: Buffer.from(bytes).toString('base64'),
275
+ },
276
+ });
277
+ }
278
+
279
+ completeStorageObject(objectId, body = {}) {
280
+ return this.request(`/api/v4/storage/${objectId}/complete`, {
281
+ method: 'POST',
282
+ body,
283
+ });
284
+ }
285
+ }
package/src/config.js ADDED
@@ -0,0 +1,117 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseConnectionToken } from './token.js';
4
+ import { loadBetterSqlite3 } from './sqlite-runtime.js';
5
+
6
+ function getDefaultStateDir() {
7
+ return path.resolve(process.cwd(), '.flightdeck-cli');
8
+ }
9
+
10
+ function getYokeStateDir() {
11
+ return path.resolve(process.cwd(), '.wingman-yoke');
12
+ }
13
+
14
+ function getLegacyStateDir() {
15
+ return path.resolve(process.cwd(), '.wingman-ap');
16
+ }
17
+
18
+ function resolveStateDir(dir) {
19
+ return path.resolve(String(dir || '').trim());
20
+ }
21
+
22
+ export function getStateDir() {
23
+ const override = String(process.env.FLIGHTDECK_CLI_STATE_DIR || '').trim();
24
+ if (override) return resolveStateDir(override);
25
+ const yokeOverride = String(process.env.WINGMAN_YOKE_STATE_DIR || '').trim();
26
+ if (yokeOverride) return resolveStateDir(yokeOverride);
27
+ const legacyOverride = String(process.env.WINGMAN_AP_STATE_DIR || '').trim();
28
+ if (legacyOverride) return resolveStateDir(legacyOverride);
29
+ const defaultDir = getDefaultStateDir();
30
+ if (fs.existsSync(defaultDir)) return defaultDir;
31
+ const yokeDir = getYokeStateDir();
32
+ if (fs.existsSync(yokeDir)) return yokeDir;
33
+ const legacyDir = getLegacyStateDir();
34
+ if (fs.existsSync(legacyDir)) return legacyDir;
35
+ return defaultDir;
36
+ }
37
+
38
+ export function getConfigPath() {
39
+ return path.join(getStateDir(), 'config.json');
40
+ }
41
+
42
+ export function getDbPath() {
43
+ const stateDir = getStateDir();
44
+ const preferredPath = path.join(stateDir, 'flightdeck-cli.db');
45
+ if (fs.existsSync(preferredPath)) return preferredPath;
46
+ const yokePath = path.join(stateDir, 'yoke.db');
47
+ if (fs.existsSync(yokePath)) return yokePath;
48
+ const legacyPath = path.join(stateDir, 'autopilot.db');
49
+ if (fs.existsSync(legacyPath)) return legacyPath;
50
+ return preferredPath;
51
+ }
52
+
53
+ export function ensureStateDir() {
54
+ fs.mkdirSync(getStateDir(), { recursive: true });
55
+ }
56
+
57
+ function withConfigDb(fn) {
58
+ ensureStateDir();
59
+ const Database = loadBetterSqlite3();
60
+ const db = new Database(getDbPath());
61
+ db.exec(`CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
62
+ try {
63
+ return fn(db);
64
+ } finally {
65
+ db.close();
66
+ }
67
+ }
68
+
69
+ function loadConfigFromDb() {
70
+ return withConfigDb((db) => {
71
+ const row = db.prepare(`SELECT value FROM app_meta WHERE key = 'config:current'`).get();
72
+ return row?.value ? JSON.parse(row.value) : null;
73
+ });
74
+ }
75
+
76
+ function saveConfigToDb(nextConfig) {
77
+ withConfigDb((db) => {
78
+ db.prepare(`
79
+ INSERT INTO app_meta (key, value)
80
+ VALUES ('config:current', ?)
81
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
82
+ `).run(JSON.stringify(nextConfig));
83
+ });
84
+ }
85
+
86
+ export function loadConfig() {
87
+ ensureStateDir();
88
+ const dbConfig = loadConfigFromDb();
89
+ if (dbConfig) return dbConfig;
90
+ const configPath = getConfigPath();
91
+ if (!fs.existsSync(configPath)) return null;
92
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
93
+ }
94
+
95
+ export function saveConfig(nextConfig) {
96
+ ensureStateDir();
97
+ saveConfigToDb(nextConfig);
98
+ fs.writeFileSync(getConfigPath(), `${JSON.stringify(nextConfig, null, 2)}\n`, 'utf8');
99
+ }
100
+
101
+ export function initConfigFromToken(rawToken) {
102
+ const parsed = parseConnectionToken(rawToken);
103
+ const config = {
104
+ version: 1,
105
+ token: parsed.rawToken,
106
+ directHttpsUrl: parsed.directHttpsUrl,
107
+ serviceNpub: parsed.serviceNpub,
108
+ workspaceOwnerNpub: parsed.workspaceOwnerNpub,
109
+ workspaceOwnerPubkey: parsed.workspaceOwnerPubkey,
110
+ appNpub: parsed.appNpub,
111
+ appPubkey: parsed.appPubkey,
112
+ createdAt: new Date().toISOString(),
113
+ updatedAt: new Date().toISOString(),
114
+ };
115
+ saveConfig(config);
116
+ return config;
117
+ }