sharetribe-cli 1.15.2 → 1.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sharetribe-cli",
3
- "version": "1.15.2",
3
+ "version": "1.16.0",
4
4
  "description": "Unofficial Sharetribe CLI - 100% compatible with flex-cli",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -42,11 +42,13 @@
42
42
  "inquirer": "^9.2.23",
43
43
  "jsedn": "^0.4.1",
44
44
  "sharetribe-flex-build-sdk": "^1.15.2",
45
- "yargs": "^18.0.0"
45
+ "yargs": "^18.0.0",
46
+ "yauzl": "^3.2.0"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@types/inquirer": "^9.0.7",
49
50
  "@types/node": "^20.17.10",
51
+ "@types/yauzl": "^2.10.3",
50
52
  "@typescript-eslint/eslint-plugin": "^8.18.2",
51
53
  "@typescript-eslint/parser": "^8.18.2",
52
54
  "esbuild": "^0.27.2",
@@ -4,16 +4,31 @@
4
4
 
5
5
  import { Command } from 'commander';
6
6
  import {
7
- pullAssets as sdkPullAssets,
8
7
  pushAssets as sdkPushAssets,
9
8
  stageAsset as sdkStageAsset,
9
+ getApiBaseUrl,
10
+ readAuth,
10
11
  } from 'sharetribe-flex-build-sdk';
11
12
  import { printError } from '../../util/output.js';
12
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
13
+ import {
14
+ readFileSync,
15
+ writeFileSync,
16
+ existsSync,
17
+ mkdirSync,
18
+ readdirSync,
19
+ statSync,
20
+ unlinkSync,
21
+ createWriteStream,
22
+ } from 'node:fs';
13
23
  import { join, dirname } from 'node:path';
14
24
  import { createHash } from 'node:crypto';
25
+ import * as http from 'node:http';
26
+ import * as https from 'node:https';
27
+ import { tmpdir } from 'node:os';
28
+ import { pipeline } from 'node:stream/promises';
15
29
  import chalk from 'chalk';
16
30
  import edn from 'jsedn';
31
+ import yauzl from 'yauzl';
17
32
 
18
33
 
19
34
  interface AssetMetadata {
@@ -21,21 +36,15 @@ interface AssetMetadata {
21
36
  assets: Array<{ path: string; 'content-hash': string }>;
22
37
  }
23
38
 
24
- /**
25
- * Reads asset metadata from .flex-cli/asset-meta.edn
26
- */
27
- function readAssetMetadata(basePath: string): AssetMetadata | null {
28
- const metaPath = join(basePath, '.flex-cli', 'asset-meta.edn');
29
- if (!existsSync(metaPath)) {
30
- return null;
31
- }
39
+ const ASSET_META_FILENAME = 'meta/asset-meta.edn';
40
+ const ASSETS_DIR = 'assets/';
41
+ const CLEAR_LINE = '\x1b[K';
42
+ const CARRIAGE_RETURN = '\r';
32
43
 
44
+ function parseAssetMetadataEdn(content: string): AssetMetadata | null {
33
45
  try {
34
- const content = readFileSync(metaPath, 'utf-8');
35
46
  const parsed = edn.parse(content);
36
-
37
- // Convert EDN map to JavaScript object
38
- const version = parsed.at(edn.kw(':version'));
47
+ const version = parsed.at(edn.kw(':version')) || parsed.at(edn.kw(':aliased-version'));
39
48
  const assets = parsed.at(edn.kw(':assets'));
40
49
 
41
50
  const assetList: Array<{ path: string; 'content-hash': string }> = [];
@@ -48,12 +57,33 @@ function readAssetMetadata(basePath: string): AssetMetadata | null {
48
57
  }
49
58
  }
50
59
 
60
+ if (!version) {
61
+ return null;
62
+ }
63
+
51
64
  return { version, assets: assetList };
52
65
  } catch {
53
66
  return null;
54
67
  }
55
68
  }
56
69
 
70
+ /**
71
+ * Reads asset metadata from .flex-cli/asset-meta.edn
72
+ */
73
+ function readAssetMetadata(basePath: string): AssetMetadata | null {
74
+ const metaPath = join(basePath, '.flex-cli', 'asset-meta.edn');
75
+ if (!existsSync(metaPath)) {
76
+ return null;
77
+ }
78
+
79
+ try {
80
+ const content = readFileSync(metaPath, 'utf-8');
81
+ return parseAssetMetadataEdn(content);
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
57
87
  /**
58
88
  * Writes asset metadata to .flex-cli/asset-meta.edn
59
89
  */
@@ -119,6 +149,35 @@ function readLocalAssets(basePath: string): Array<{ path: string; data: Buffer;
119
149
  return assets;
120
150
  }
121
151
 
152
+ /**
153
+ * Lists local asset paths without reading file data
154
+ */
155
+ function listLocalAssetPaths(basePath: string): string[] {
156
+ const assets: string[] = [];
157
+
158
+ function scanDir(dir: string, relativePath: string = '') {
159
+ const entries = readdirSync(dir);
160
+
161
+ for (const entry of entries) {
162
+ if (entry === '.flex-cli') continue;
163
+ if (entry === '.DS_Store') continue;
164
+
165
+ const fullPath = join(dir, entry);
166
+ const relPath = relativePath ? join(relativePath, entry) : entry;
167
+ const stat = statSync(fullPath);
168
+
169
+ if (stat.isDirectory()) {
170
+ scanDir(fullPath, relPath);
171
+ } else if (stat.isFile()) {
172
+ assets.push(relPath);
173
+ }
174
+ }
175
+ }
176
+
177
+ scanDir(basePath);
178
+ return assets;
179
+ }
180
+
122
181
  /**
123
182
  * Validates JSON files
124
183
  */
@@ -134,6 +193,186 @@ function validateJsonAssets(assets: Array<{ path: string; data: Buffer }>): void
134
193
  }
135
194
  }
136
195
 
196
+ function formatDownloadProgress(bytes: number): string {
197
+ const mb = bytes / 1024 / 1024;
198
+ return `${CARRIAGE_RETURN}${CLEAR_LINE}Downloaded ${mb.toFixed(2)}MB`;
199
+ }
200
+
201
+ function printDownloadProgress(stream: NodeJS.ReadableStream): void {
202
+ let downloaded = 0;
203
+ const printProgress = (): void => {
204
+ process.stderr.write(formatDownloadProgress(downloaded));
205
+ };
206
+ const interval = setInterval(printProgress, 100);
207
+
208
+ stream.on('data', (chunk: Buffer) => {
209
+ downloaded += chunk.length;
210
+ });
211
+
212
+ stream.on('end', () => {
213
+ clearInterval(interval);
214
+ printProgress();
215
+ process.stderr.write('\nFinished downloading assets\n');
216
+ });
217
+ }
218
+
219
+ function getApiKeyOrThrow(): string {
220
+ const auth = readAuth();
221
+ if (!auth?.apiKey) {
222
+ throw new Error('Not logged in. Please provide apiKey or run: sharetribe-cli login');
223
+ }
224
+ return auth.apiKey;
225
+ }
226
+
227
+ function getAssetsPullUrl(marketplace: string, version?: string): URL {
228
+ const url = new URL(getApiBaseUrl() + '/assets/pull');
229
+ url.searchParams.set('marketplace', marketplace);
230
+ if (version) {
231
+ url.searchParams.set('version', version);
232
+ } else {
233
+ url.searchParams.set('version-alias', 'latest');
234
+ }
235
+ return url;
236
+ }
237
+
238
+ function getErrorMessage(body: string, statusCode: number): string {
239
+ try {
240
+ const parsed = JSON.parse(body) as { errors?: Array<{ message?: string }> };
241
+ const message = parsed.errors?.[0]?.message;
242
+ if (message) {
243
+ return message;
244
+ }
245
+ } catch {
246
+ // Ignore JSON parse errors
247
+ }
248
+ return body || `HTTP ${statusCode}`;
249
+ }
250
+
251
+ async function getAssetsZipStream(
252
+ marketplace: string,
253
+ version?: string
254
+ ): Promise<http.IncomingMessage> {
255
+ const url = getAssetsPullUrl(marketplace, version);
256
+ const apiKey = getApiKeyOrThrow();
257
+ const isHttps = url.protocol === 'https:';
258
+ const client = isHttps ? https : http;
259
+
260
+ return new Promise((resolve, reject) => {
261
+ const req = client.request(
262
+ {
263
+ method: 'GET',
264
+ hostname: url.hostname,
265
+ port: url.port || (isHttps ? 443 : 80),
266
+ path: url.pathname + url.search,
267
+ headers: {
268
+ Authorization: `Apikey ${apiKey}`,
269
+ Accept: 'application/zip',
270
+ },
271
+ },
272
+ (res) => {
273
+ const statusCode = res.statusCode || 0;
274
+ if (statusCode < 200 || statusCode >= 300) {
275
+ const chunks: Buffer[] = [];
276
+ res.on('data', (chunk: Buffer) => chunks.push(chunk));
277
+ res.on('end', () => {
278
+ const body = Buffer.concat(chunks).toString('utf-8');
279
+ reject(new Error(getErrorMessage(body, statusCode)));
280
+ });
281
+ return;
282
+ }
283
+ resolve(res);
284
+ }
285
+ );
286
+
287
+ req.setTimeout(120000, () => {
288
+ req.destroy(new Error('Request timeout'));
289
+ });
290
+ req.on('error', reject);
291
+ req.end();
292
+ });
293
+ }
294
+
295
+ function createTempZipPath(): string {
296
+ return join(tmpdir(), `assets-${Date.now()}.zip`);
297
+ }
298
+
299
+ function removeAssetsDir(filename: string): string {
300
+ if (filename.startsWith(ASSETS_DIR)) {
301
+ return filename.slice(ASSETS_DIR.length);
302
+ }
303
+ return filename;
304
+ }
305
+
306
+ function readStreamToString(stream: NodeJS.ReadableStream): Promise<string> {
307
+ return new Promise((resolve, reject) => {
308
+ const chunks: Buffer[] = [];
309
+ stream.on('data', (chunk: Buffer) => chunks.push(chunk));
310
+ stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
311
+ stream.on('error', reject);
312
+ });
313
+ }
314
+
315
+ async function unzipAssets(zipPath: string, basePath: string): Promise<AssetMetadata> {
316
+ return new Promise((resolve, reject) => {
317
+ yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
318
+ if (err || !zipfile) {
319
+ reject(err || new Error('Failed to open zip file'));
320
+ return;
321
+ }
322
+
323
+ let assetMeta: AssetMetadata | null = null;
324
+
325
+ zipfile.on('error', reject);
326
+ zipfile.on('end', () => {
327
+ if (!assetMeta) {
328
+ reject(new Error('Asset metadata not found in zip'));
329
+ return;
330
+ }
331
+ resolve(assetMeta);
332
+ });
333
+
334
+ zipfile.readEntry();
335
+ zipfile.on('entry', (entry) => {
336
+ if (entry.fileName.endsWith('/')) {
337
+ zipfile.readEntry();
338
+ return;
339
+ }
340
+
341
+ zipfile.openReadStream(entry, (streamErr, readStream) => {
342
+ if (streamErr || !readStream) {
343
+ reject(streamErr || new Error('Failed to read zip entry'));
344
+ return;
345
+ }
346
+
347
+ if (entry.fileName === ASSET_META_FILENAME) {
348
+ readStreamToString(readStream)
349
+ .then((content) => {
350
+ assetMeta = parseAssetMetadataEdn(content);
351
+ if (!assetMeta) {
352
+ reject(new Error('Invalid asset metadata'));
353
+ return;
354
+ }
355
+ zipfile.readEntry();
356
+ })
357
+ .catch(reject);
358
+ return;
359
+ }
360
+
361
+ const assetPath = join(basePath, removeAssetsDir(entry.fileName));
362
+ const assetDir = dirname(assetPath);
363
+ if (!existsSync(assetDir)) {
364
+ mkdirSync(assetDir, { recursive: true });
365
+ }
366
+
367
+ pipeline(readStream, createWriteStream(assetPath))
368
+ .then(() => zipfile.readEntry())
369
+ .catch(reject);
370
+ });
371
+ });
372
+ });
373
+ });
374
+ }
375
+
137
376
  /**
138
377
  * Pulls assets from remote
139
378
  */
@@ -154,57 +393,48 @@ async function pullAssets(
154
393
  throw new Error(`${path} is not a directory`);
155
394
  }
156
395
 
157
- // Fetch assets from API
158
- const result = await sdkPullAssets(undefined, marketplace, version ? { version } : undefined);
159
- const remoteVersion = result.version;
160
-
161
- // Read current metadata
396
+ const localAssets = prune ? listLocalAssetPaths(path) : [];
162
397
  const currentMeta = readAssetMetadata(path);
398
+ const tempZipPath = createTempZipPath();
163
399
 
164
- // Check if up to date
165
- if (currentMeta && currentMeta.version === remoteVersion && result.assets.length === currentMeta.assets.length) {
166
- console.log('Assets are up to date.');
167
- return;
168
- }
400
+ try {
401
+ const zipStream = await getAssetsZipStream(marketplace, version);
402
+ printDownloadProgress(zipStream);
403
+ await pipeline(zipStream, createWriteStream(tempZipPath));
169
404
 
170
- // Write assets to disk
171
- const newAssets: Array<{ path: string; 'content-hash': string }> = [];
172
- for (const asset of result.assets) {
173
- const assetPath = join(path, asset.path);
174
- const assetDir = dirname(assetPath);
175
-
176
- if (!existsSync(assetDir)) {
177
- mkdirSync(assetDir, { recursive: true });
178
- }
405
+ const newAssetMeta = await unzipAssets(tempZipPath, path);
406
+ const remoteVersion = newAssetMeta.version;
179
407
 
180
- // Decode base64 data
181
- const data = Buffer.from(asset.dataRaw, 'base64');
182
- writeFileSync(assetPath, data);
408
+ const deletedPaths = prune
409
+ ? new Set(localAssets.filter(p => !newAssetMeta.assets.some(a => a.path === p)))
410
+ : new Set<string>();
183
411
 
184
- const hash = calculateHash(data);
185
- newAssets.push({ path: asset.path, 'content-hash': asset.contentHash || hash });
186
- }
412
+ const updated = currentMeta?.version !== remoteVersion;
413
+ const shouldReportUpdate = updated || deletedPaths.size > 0;
187
414
 
188
- // Prune deleted assets if requested
189
- if (prune && currentMeta) {
190
- const remotePaths = new Set(result.assets.map(a => a.path));
191
- for (const localAsset of currentMeta.assets) {
192
- if (!remotePaths.has(localAsset.path)) {
193
- const assetPath = join(path, localAsset.path);
194
- if (existsSync(assetPath)) {
195
- unlinkSync(assetPath);
415
+ if (deletedPaths.size > 0) {
416
+ for (const assetPath of deletedPaths) {
417
+ const fullPath = join(path, assetPath);
418
+ if (existsSync(fullPath)) {
419
+ unlinkSync(fullPath);
196
420
  }
197
421
  }
198
422
  }
199
- }
200
423
 
201
- // Update metadata
202
- writeAssetMetadata(path, {
203
- version: remoteVersion,
204
- assets: newAssets,
205
- });
206
-
207
- console.log(`Version ${remoteVersion} successfully pulled.`);
424
+ if (shouldReportUpdate) {
425
+ writeAssetMetadata(path, {
426
+ version: remoteVersion,
427
+ assets: newAssetMeta.assets,
428
+ });
429
+ console.log(`Version ${remoteVersion} successfully pulled.`);
430
+ } else {
431
+ console.log('Assets are up to date.');
432
+ }
433
+ } finally {
434
+ if (existsSync(tempZipPath)) {
435
+ unlinkSync(tempZipPath);
436
+ }
437
+ }
208
438
  } catch (error) {
209
439
  if (error && typeof error === 'object' && 'message' in error) {
210
440
  printError(error.message as string);
@@ -396,3 +626,9 @@ export function registerAssetsCommands(program: Command): void {
396
626
  await pushAssets(marketplace, opts.path, opts.prune);
397
627
  });
398
628
  }
629
+
630
+ export const __test__ = {
631
+ formatDownloadProgress,
632
+ removeAssetsDir,
633
+ parseAssetMetadataEdn,
634
+ };
@@ -2,7 +2,6 @@
2
2
  * Debug command - display config and auth info
3
3
  */
4
4
 
5
- import edn from 'jsedn';
6
5
  import { getConfigMap, readAuth } from 'sharetribe-flex-build-sdk';
7
6
 
8
7
  function maskLast4(value: string): string {
@@ -12,25 +11,16 @@ function maskLast4(value: string): string {
12
11
  return `...${value.slice(-4)}`;
13
12
  }
14
13
 
15
- function toEdnMap(record: Record<string, string>): edn.Map {
16
- const entries: Array<unknown> = [];
17
- for (const [key, value] of Object.entries(record)) {
18
- entries.push(edn.kw(`:${key}`), value);
19
- }
20
- return new edn.Map(entries);
21
- }
22
-
23
14
  export function debug(): void {
24
15
  const auth = readAuth();
25
16
  const apiKey = auth?.apiKey ? maskLast4(auth.apiKey) : 'No API key set';
26
17
  const confMap = getConfigMap();
27
18
 
28
- const payload = new edn.Map([
29
- edn.kw(':api-key'),
30
- apiKey,
31
- edn.kw(':conf-map'),
32
- toEdnMap(confMap),
33
- ]);
19
+ const confMapEntries = Object.keys(confMap)
20
+ .sort()
21
+ .map((key) => `:${key} ${confMap[key]}`)
22
+ .join(' ');
23
+ const confMapFormatted = confMapEntries ? `{${confMapEntries}}` : '{}';
34
24
 
35
- console.log(edn.encode(payload));
25
+ console.log(`{:api-key ${apiKey}, :conf-map ${confMapFormatted}}`);
36
26
  }
@@ -208,12 +208,18 @@ export function registerSearchCommands(program: Command): void {
208
208
  searchCmd
209
209
  .command('set')
210
210
  .description('set search schema')
211
- .requiredOption('--key <KEY>', 'schema key')
212
- .requiredOption('--scope <SCOPE>', 'schema scope')
213
- .requiredOption('--type <TYPE>', 'value type (enum, multi-enum, boolean, long, or text)')
211
+ .requiredOption('--key <KEY>', 'key name')
212
+ .requiredOption(
213
+ '--scope <SCOPE>',
214
+ 'extended data scope (either metadata or public for listing schema, metadata, private, protected or public for userProfile schema, metadata or protected for transaction schema)'
215
+ )
216
+ .requiredOption('--type <TYPE>', 'value type (either enum, multi-enum, boolean, long or text)')
214
217
  .option('--doc <DOC>', 'description of the schema')
215
218
  .option('--default <DEFAULT>', 'default value for search if value is not set')
216
- .option('--schema-for <SCHEMA_FOR>', 'subject of the schema (listing, userProfile, or transaction)')
219
+ .option(
220
+ '--schema-for <SCHEMA_FOR>',
221
+ 'Subject of the schema (either listing, userProfile or transaction, defaults to listing)'
222
+ )
217
223
  .option('-m, --marketplace <MARKETPLACE_ID>', 'marketplace identifier')
218
224
  .action(async (opts) => {
219
225
  const marketplace = opts.marketplace || program.opts().marketplace;
@@ -235,9 +241,15 @@ export function registerSearchCommands(program: Command): void {
235
241
  searchCmd
236
242
  .command('unset')
237
243
  .description('unset search schema')
238
- .requiredOption('--key <KEY>', 'schema key')
239
- .requiredOption('--scope <SCOPE>', 'schema scope')
240
- .option('--schema-for <SCHEMA_FOR>', 'subject of the schema (listing, userProfile, or transaction)')
244
+ .requiredOption('--key <KEY>', 'key name')
245
+ .requiredOption(
246
+ '--scope <SCOPE>',
247
+ 'extended data scope (either metadata or public for listing schema, metadata, private, protected or public for userProfile schema, metadata or protected for transaction schema)'
248
+ )
249
+ .option(
250
+ '--schema-for <SCHEMA_FOR>',
251
+ 'Subject of the schema (either listing, userProfile or transaction, defaults to listing)'
252
+ )
241
253
  .option('-m, --marketplace <MARKETPLACE_ID>', 'marketplace identifier')
242
254
  .action(async (opts) => {
243
255
  const marketplace = opts.marketplace || program.opts().marketplace;
@@ -7,6 +7,9 @@ import { mkdtempSync, writeFileSync, existsSync, rmSync, readdirSync } from 'fs'
7
7
  import { join } from 'path';
8
8
  import { tmpdir } from 'os';
9
9
  import { createHash } from 'node:crypto';
10
+ import { __test__ as assetsTestHelpers } from '../src/commands/assets/index.js';
11
+
12
+ const { formatDownloadProgress } = assetsTestHelpers;
10
13
 
11
14
  /**
12
15
  * Calculates SHA-1 hash matching backend convention
@@ -139,3 +142,15 @@ describe('Asset Type Detection', () => {
139
142
  expect(isJsonAsset('test.svg')).toBe(false);
140
143
  });
141
144
  });
145
+
146
+ describe('Asset Pull Progress Output', () => {
147
+ it('formats progress with carriage return and clear line', () => {
148
+ const output = formatDownloadProgress(0);
149
+ expect(output).toBe('\r\x1b[KDownloaded 0.00MB');
150
+ });
151
+
152
+ it('formats progress with two decimal MB values', () => {
153
+ const output = formatDownloadProgress(1024 * 1024);
154
+ expect(output).toBe('\r\x1b[KDownloaded 1.00MB');
155
+ });
156
+ });
@@ -173,6 +173,35 @@ describe('Help Comparison Tests', () => {
173
173
  });
174
174
  });
175
175
 
176
+ describe('help search set option descriptions', () => {
177
+ it('matches flex-cli wording for key and scope options', () => {
178
+ const shareOutput = runCli('help search set', 'sharetribe');
179
+ expect(shareOutput).toContain('key name');
180
+ expect(shareOutput).toContain('extended data scope (either metadata or public for listing schema,');
181
+ expect(shareOutput).toContain('metadata, private, protected or public for userProfile schema,');
182
+ expect(shareOutput).toContain('metadata or protected for transaction schema)');
183
+ });
184
+
185
+ it('matches flex-cli wording for type and schema-for options', () => {
186
+ const shareOutput = runCli('help search set', 'sharetribe');
187
+ expect(shareOutput).toContain('value type (either enum, multi-enum, boolean, long or text)');
188
+ expect(shareOutput).toContain('Subject of the schema (either listing, userProfile or transaction,');
189
+ expect(shareOutput).toContain('defaults to listing');
190
+ });
191
+ });
192
+
193
+ describe('help search unset option descriptions', () => {
194
+ it('matches flex-cli wording for key, scope, and schema-for options', () => {
195
+ const shareOutput = runCli('help search unset', 'sharetribe');
196
+ expect(shareOutput).toContain('key name');
197
+ expect(shareOutput).toContain('extended data scope (either metadata or public for listing schema,');
198
+ expect(shareOutput).toContain('metadata, private, protected or public for userProfile schema,');
199
+ expect(shareOutput).toContain('metadata or protected for transaction schema)');
200
+ expect(shareOutput).toContain('Subject of the schema (either listing, userProfile or transaction,');
201
+ expect(shareOutput).toContain('defaults to listing');
202
+ });
203
+ });
204
+
176
205
  describe('help notifications', () => {
177
206
  it('has correct structure', () => {
178
207
  const shareOutput = runCli('help notifications', 'sharetribe');