@descix/cli 1.0.8 → 1.0.9

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.
@@ -91,11 +91,17 @@ ASSET NOT FOUND at gs://... Upload it first with `descix app media-upload`.
91
91
  Both come back as `MEDIA_ASSET_UNREADABLE`. The first means you referenced the wrong app's asset;
92
92
  the second means you have not uploaded yet. Upload into the app that will do the asking.
93
93
 
94
- **The upload verb hands you both forms, and both work.** `descix app media-upload` returns a
95
- `path` (relative to the app's assets prefix) and a `ref` (the `gs://` URI) for every file, plus a
96
- `public_url`; `--json` prints them for scripting. Either `path` or `ref` is a valid `asset_ref` —
97
- so if a rule in your head says the tool's own `ref` field is unusable, the rule is wrong, not the
98
- tool.
94
+ **The upload verb hands you the relative `path`, and it is a valid `asset_ref` as-is.** `descix
95
+ app media-upload` returns, per file, a `path` (relative to the app's assets prefix), a
96
+ `public_url` (the app-host form, e.g. `https://{app}.descix.net/assets/<path>`), `gcs_path` and
97
+ `content_type`; `--json` prints them for scripting. Pass `path` (or a `gs://` URI you construct
98
+ yourself) as `asset_ref` — either form works, see below.
99
+
100
+ **Who can upload, and how much.** Any app owner, community admin or platform admin may call
101
+ `media-upload` for that app. Storage is capped **200 MB per app today** (pending credit-based
102
+ metering for more) — the cap is enforced server-side by `get_asset_upload_token`, which refuses
103
+ with its own message when a would-be upload exceeds it; the CLI never pre-checks quota locally,
104
+ so trust that refusal over any local math.
99
105
 
100
106
  **The `gs://` form is accepted.** A `gs://` URI works as an `asset_ref` — it is parsed and
101
107
  scope-checked, and the handler then looks the object up by path. What is PROVEN is the form and
package/bin/descix.js CHANGED
@@ -40,35 +40,12 @@ import { kbVectorCell, kbCountSource } from '../lib/commands/kb-list-render.js';
40
40
  import * as corpusCommands from '../lib/commands/corpus.js';
41
41
  import * as modelConfigCommands from '../lib/commands/model-config.js';
42
42
  import { isHelpInvocation, getCommandSurface, applyVisibility } from '../lib/command-visibility.js';
43
+ import { progress } from '../lib/output.js';
44
+ import { runMediaUpload } from '../lib/commands/media-upload.js';
43
45
  import fs from 'fs/promises';
44
46
  import path from 'path';
45
47
  import os from 'os';
46
48
 
47
-
48
- /**
49
- * Emit human-facing PROGRESS for a command that can also emit JSON.
50
- *
51
- * When `--json` is set, stdout is a DATA CHANNEL: it must contain the JSON document and
52
- * nothing else, or every scripted consumer breaks. Progress then goes to stderr, where a
53
- * human piping stdout still sees it. Without `--json`, stdout is the human channel and
54
- * progress belongs there.
55
- *
56
- * ONE OWNER of that decision. Before this existed, each --json command re-decided it by
57
- * calling console.log directly, and both of them got it wrong: `mcp execute --json` prefixed
58
- * the document with "Executing <tool>..." and `app media-upload --json` prefixed it with a
59
- * header, a per-file listing and a per-file +/x line. Both exited 0, so a consumer saw a
60
- * success code and an unparseable stream. Measured 2026-08-21/22 (seat BEAST): two seats had
61
- * independently hand-rolled prefix-stripping workarounds (`tail -n +2`, `raw.find('[')`)
62
- * rather than reporting it.
63
- *
64
- * @param {Object} options - the command's parsed options (read for .json)
65
- * @param {...any} args - passed through to console.log / console.error
66
- */
67
- function progress(options, ...args) {
68
- if (options && options.json) console.error(...args);
69
- else console.log(...args);
70
- }
71
-
72
49
  /**
73
50
  * THE ONE OWNER of "a command threw, and the process must stop saying why".
74
51
  *
@@ -1196,15 +1173,12 @@ appCommand
1196
1173
 
1197
1174
 
1198
1175
  // descix app media-upload — upload media/asset files to an app's GCS assets prefix via the
1199
- // API surface (WS-V1-PURGE Phase 1, item 2; media-via-API-surface PLATFORM half).
1200
- // This is the canonical, filesystem-free way to get app media (podcast audio, cover art,
1201
- // etc.) into the platform: request a short-lived signed PUT token from the API surface
1202
- // (`get_asset_upload_token` over /apifront, user-session authed), upload each file straight
1203
- // to GCS, and print the ASSET REFERENCES. An app microservice then fetches an uploaded asset
1204
- // by reference over the Core broker via `get_app_asset` (no shared local filesystem needed).
1176
+ // API surface (WS-V1-PURGE Phase 1, item 2; media-via-API-surface PLATFORM half). Command body
1177
+ // lives in lib/commands/media-upload.js (runMediaUpload) — see that module for the full
1178
+ // contract (server-owned quota/path/permission checks, verbatim upload_headers ferrying).
1205
1179
  appCommand
1206
1180
  .command('media-upload')
1207
- .description('Upload media/asset files to an app\'s GCS assets prefix via the API surface (returns asset references)')
1181
+ .description('Upload media/asset files to an app\'s GCS assets prefix (200 MB per app today; app owners, community admins and platform admins may upload — returns asset references)')
1208
1182
  .requiredOption('-a, --app <id>', 'App ID (community is resolved server-side from Products)')
1209
1183
  .requiredOption('-f, --file <path...>', 'One or more local file paths to upload')
1210
1184
  .option('--prefix <relPath>', 'Optional sub-path under the app assets/ prefix (e.g. "shows/myshow")', '')
@@ -1213,115 +1187,7 @@ appCommand
1213
1187
  try {
1214
1188
  const apiClient = new DeSciXApiClient();
1215
1189
  await requireAuth(apiClient);
1216
-
1217
- const path = await import('path');
1218
- const mime = (await import('mime-types')).default;
1219
-
1220
- const appId = options.app;
1221
- const localFiles = Array.isArray(options.file) ? options.file : [options.file];
1222
- const subPrefix = (options.prefix || '').replace(/^\/+|\/+$/g, '');
1223
-
1224
- // Build the upload descriptor list: object path under assets/ = [subPrefix/]basename.
1225
- const fileDescriptors = [];
1226
- for (const localPath of localFiles) {
1227
- const abs = path.resolve(localPath);
1228
- let stat;
1229
- try {
1230
- stat = await fs.stat(abs);
1231
- } catch {
1232
- console.error(chalk.red(`\n❌ File not found: ${abs}\n`));
1233
- process.exit(1);
1234
- }
1235
- if (!stat.isFile()) {
1236
- console.error(chalk.red(`\n❌ Not a file: ${abs}\n`));
1237
- process.exit(1);
1238
- }
1239
- const base = path.basename(abs);
1240
- const objectPath = subPrefix ? `${subPrefix}/${base}` : base;
1241
- fileDescriptors.push({
1242
- path: objectPath,
1243
- content_type: mime.lookup(abs) || 'application/octet-stream',
1244
- size: stat.size,
1245
- _absolutePath: abs
1246
- });
1247
- }
1248
-
1249
- console.log(chalk.cyan(`\n Media Upload: ${appId} → GCS assets/\n`));
1250
- fileDescriptors.forEach(f => progress(options, chalk.gray(` • ${f.path} (${(f.size / 1024).toFixed(1)}KB, ${f.content_type})`)));
1251
-
1252
- // 1. Request a signed-PUT upload token over the API surface.
1253
- const tokenResponse = await apiClient.invoke('get_asset_upload_token', {
1254
- app_id: appId,
1255
- files: fileDescriptors.map(f => ({ path: f.path, content_type: f.content_type, size: f.size }))
1256
- });
1257
- const token = tokenResponse.message || tokenResponse;
1258
- const { signed_urls, objects } = token;
1259
-
1260
- // 2. PUT each file directly to GCS using its signed URL.
1261
- progress(options, chalk.gray(`\n Uploading ${fileDescriptors.length} file(s)...`));
1262
- const uploaded = [];
1263
- const errors = [];
1264
- for (const f of fileDescriptors) {
1265
- const signedUrl = signed_urls?.[f.path];
1266
- if (!signedUrl) {
1267
- errors.push(`No signed URL for: ${f.path}`);
1268
- progress(options, chalk.red(` x ${f.path}`));
1269
- continue;
1270
- }
1271
- try {
1272
- const content = await fs.readFile(f._absolutePath);
1273
- const resp = await fetch(signedUrl, {
1274
- method: 'PUT',
1275
- headers: { 'Content-Type': f.content_type },
1276
- body: content
1277
- });
1278
- if (!resp.ok) {
1279
- errors.push(`Failed ${f.path}: ${resp.status} ${resp.statusText}`);
1280
- progress(options, chalk.red(` x ${f.path}`));
1281
- } else {
1282
- const obj = (objects || []).find(o => o.path === f.path) || {};
1283
- // The asset REFERENCE an app handler / get_app_asset consumes: the gs:// URI.
1284
- const gcsRef = obj.gcs_path
1285
- ? `gs://${token.bucket}/${obj.gcs_path}`
1286
- : null;
1287
- uploaded.push({
1288
- path: f.path,
1289
- ref: gcsRef,
1290
- gcs_path: obj.gcs_path || null,
1291
- public_url: obj.public_url || null,
1292
- content_type: f.content_type,
1293
- size: f.size
1294
- });
1295
- progress(options, chalk.green(` + ${f.path}`));
1296
- }
1297
- } catch (err) {
1298
- errors.push(`Error ${f.path}: ${err.message}`);
1299
- progress(options, chalk.red(` x ${f.path}`));
1300
- }
1301
- }
1302
-
1303
- if (errors.length > 0) {
1304
- progress(options, chalk.yellow(`\n ${errors.length} error(s):`));
1305
- errors.forEach(e => progress(options, chalk.red(` - ${e}`)));
1306
- }
1307
-
1308
- if (uploaded.length === 0) {
1309
- console.error(chalk.red('\n❌ No files uploaded.\n'));
1310
- process.exit(1);
1311
- }
1312
-
1313
- if (options.json) {
1314
- console.log('\n' + JSON.stringify({ app_id: appId, assets: uploaded }, null, 2) + '\n');
1315
- } else {
1316
- console.log(chalk.green(`\n ✅ Uploaded ${uploaded.length} asset(s).\n`));
1317
- console.log(chalk.cyan(' Asset references (pass these to your app handler; the service fetches via get_app_asset):'));
1318
- uploaded.forEach(u => {
1319
- console.log(chalk.white(` ${u.path}`));
1320
- console.log(chalk.gray(` ref: ${u.ref}`));
1321
- console.log(chalk.gray(` public_url: ${u.public_url}`));
1322
- });
1323
- console.log();
1324
- }
1190
+ await runMediaUpload(apiClient, options);
1325
1191
  } catch (error) {
1326
1192
  console.error(chalk.red(`\n❌ ${error.message}\n`));
1327
1193
  process.exit(1);
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `descix app media-upload` — upload media/asset files to an app's GCS assets prefix via the
3
+ * API surface (WS-V1-PURGE Phase 1, item 2; media-via-API-surface PLATFORM half; storage-cap
4
+ * ferrying added WS-MEDIA-QUOTA per CEO ruling 2026-09-16: "Yes you can build with a 200MB
5
+ * size limit for now until we get credit based metering for more storage.").
6
+ *
7
+ * This is the canonical, filesystem-free way to get app media (podcast audio, cover art, etc.)
8
+ * into the platform: request a short-lived signed PUT token from the API surface
9
+ * (`get_asset_upload_token` over /apifront, user-session authed), upload each file straight to
10
+ * GCS, and print the server's asset references. An app microservice then fetches an uploaded
11
+ * asset by reference over the Core broker via `get_app_asset` (no shared local filesystem
12
+ * needed). App owners, community admins and platform admins may upload — the server is the
13
+ * sole authority on that and on the app's storage quota (200 MB per app today; nothing here
14
+ * pre-validates either).
15
+ *
16
+ * SERVER IS THE SOLE AUTHORITY (super-DRY: never re-derive a server-owned fact client-side):
17
+ * - quota, path validity (no leading `/`, no backslash, no empty/`.`/`..` segment) and caller
18
+ * permission are enforced ONLY by `get_asset_upload_token`; a refusal there throws with the
19
+ * server's message verbatim (`DeSciXApiClient#invoke`) and this module does not catch it —
20
+ * let it propagate so the caller prints exactly what the server said and exits non-zero.
21
+ * - `--prefix` is passed through UNCHANGED (no slash-stripping): stripping could silently mask
22
+ * a server-side path refusal the server is best positioned to name.
23
+ * - Each file is PUT with the server's `upload_headers[path]` bag VERBATIM (precedent: `site
24
+ * upload`'s `ferriedHeaders` — the server binds Content-Type and
25
+ * `x-goog-content-length-range` into the v4 signed-URL signature, so a re-derived header
26
+ * 403s). Size is still read from `fs.stat`, since GCS itself rejects a body whose length
27
+ * disagrees with the declared range.
28
+ * - `public_url` and `storage` are printed exactly as the server returned them — never
29
+ * reconstructed from `bucket` + `gcs_path` (that WAS a client-side re-derivation of a server
30
+ * fact; removed here rather than perpetuated).
31
+ */
32
+
33
+ import * as fs from 'fs/promises';
34
+ import * as path from 'path';
35
+ import chalk from 'chalk';
36
+ import { progress } from '../output.js';
37
+
38
+ /**
39
+ * Run `app media-upload`.
40
+ *
41
+ * @param {import('../api-client.js').DeSciXApiClient} apiClient
42
+ * @param {{app:string, file:string|string[], prefix?:string, json?:boolean}} options
43
+ * @returns {Promise<{app_id:string, assets:Array<object>, storage:object|null}>}
44
+ */
45
+ export async function runMediaUpload(apiClient, options) {
46
+ const mime = (await import('mime-types')).default;
47
+
48
+ const appId = options.app;
49
+ const localFiles = Array.isArray(options.file) ? options.file : [options.file];
50
+ // No local normalization: a leading/trailing slash, empty segment, or `.`/`..` segment is a
51
+ // server-owned validity rule (get_asset_upload_token) — pass --prefix through untouched so a
52
+ // bad prefix surfaces as the server's own refusal, never a silently-corrected local guess.
53
+ const subPrefix = options.prefix || '';
54
+
55
+ // Build the upload descriptor list: object path under assets/ = [subPrefix/]basename.
56
+ const fileDescriptors = [];
57
+ for (const localPath of localFiles) {
58
+ const abs = path.resolve(localPath);
59
+ let stat;
60
+ try {
61
+ stat = await fs.stat(abs);
62
+ } catch {
63
+ throw new Error(`File not found: ${abs}`);
64
+ }
65
+ if (!stat.isFile()) {
66
+ throw new Error(`Not a file: ${abs}`);
67
+ }
68
+ const base = path.basename(abs);
69
+ const objectPath = subPrefix ? `${subPrefix}/${base}` : base;
70
+ fileDescriptors.push({
71
+ path: objectPath,
72
+ content_type: mime.lookup(abs) || 'application/octet-stream',
73
+ size: stat.size,
74
+ _absolutePath: abs
75
+ });
76
+ }
77
+
78
+ progress(options, chalk.cyan(`\n Media Upload: ${appId} → GCS assets/\n`));
79
+ fileDescriptors.forEach(f => progress(options, chalk.gray(` • ${f.path} (${(f.size / 1024).toFixed(1)}KB, ${f.content_type})`)));
80
+
81
+ // 1. Request a signed-PUT upload token over the API surface. The server owns quota, path
82
+ // validity and caller permission — a refusal here throws with the server's message verbatim
83
+ // and this function does not catch it.
84
+ const tokenResponse = await apiClient.invoke('get_asset_upload_token', {
85
+ app_id: appId,
86
+ files: fileDescriptors.map(f => ({ path: f.path, content_type: f.content_type, size: f.size }))
87
+ });
88
+ const token = tokenResponse.message || tokenResponse;
89
+ const { signed_urls, upload_headers, objects, storage } = token;
90
+
91
+ // 2. PUT each file directly to GCS using its signed URL and the server's exact header bag.
92
+ progress(options, chalk.gray(`\n Uploading ${fileDescriptors.length} file(s)...`));
93
+ const uploaded = [];
94
+ const errors = [];
95
+ for (const f of fileDescriptors) {
96
+ const signedUrl = signed_urls?.[f.path];
97
+ if (!signedUrl) {
98
+ errors.push(`No signed URL for: ${f.path}`);
99
+ progress(options, chalk.red(` x ${f.path}`));
100
+ continue;
101
+ }
102
+ // Never re-derive Content-Type (or the length-range header) locally for the PUT — the
103
+ // server binds them into the v4 signature. A path the server did not ferry a header bag
104
+ // for is an error for that path, naming the server as the thing that is missing it.
105
+ const headers = upload_headers?.[f.path];
106
+ if (!headers) {
107
+ errors.push(
108
+ `No upload_headers for: ${f.path} (get_asset_upload_token response is missing the header ` +
109
+ `bag for this path)`
110
+ );
111
+ progress(options, chalk.red(` x ${f.path}`));
112
+ continue;
113
+ }
114
+ try {
115
+ const content = await fs.readFile(f._absolutePath);
116
+ const resp = await fetch(signedUrl, {
117
+ method: 'PUT',
118
+ headers,
119
+ body: content
120
+ });
121
+ if (!resp.ok) {
122
+ errors.push(`Failed ${f.path}: ${resp.status} ${resp.statusText}`);
123
+ progress(options, chalk.red(` x ${f.path}`));
124
+ } else {
125
+ const obj = (objects || []).find(o => o.path === f.path) || {};
126
+ uploaded.push({
127
+ path: f.path,
128
+ gcs_path: obj.gcs_path || null,
129
+ public_url: obj.public_url || null,
130
+ content_type: f.content_type,
131
+ size: f.size
132
+ });
133
+ progress(options, chalk.green(` + ${f.path}`));
134
+ }
135
+ } catch (err) {
136
+ errors.push(`Error ${f.path}: ${err.message}`);
137
+ progress(options, chalk.red(` x ${f.path}`));
138
+ }
139
+ }
140
+
141
+ if (errors.length > 0) {
142
+ progress(options, chalk.yellow(`\n ${errors.length} error(s):`));
143
+ errors.forEach(e => progress(options, chalk.red(` - ${e}`)));
144
+ }
145
+
146
+ if (uploaded.length === 0) {
147
+ throw new Error('No files uploaded.');
148
+ }
149
+ if (errors.length > 0) {
150
+ // FAIL LOUD on a partial upload: the files that went up are listed above, but a caller that
151
+ // reads only the exit status must not take a half-uploaded set as success.
152
+ throw new Error(`${errors.length} of ${fileDescriptors.length} file(s) failed to upload:\n - ${errors.join('\n - ')}`);
153
+ }
154
+
155
+ const result = { app_id: appId, assets: uploaded, storage: storage || null };
156
+
157
+ if (options.json) {
158
+ console.log('\n' + JSON.stringify(result, null, 2) + '\n');
159
+ } else {
160
+ console.log(chalk.green(`\n ✅ Uploaded ${uploaded.length} asset(s).\n`));
161
+ console.log(chalk.cyan(' Asset references (pass the path to your app handler; the service fetches via get_app_asset):'));
162
+ uploaded.forEach(u => {
163
+ console.log(chalk.white(` ${u.path}`));
164
+ console.log(chalk.gray(` public_url: ${u.public_url}`));
165
+ });
166
+ if (result.storage) {
167
+ // projected_bytes is the app's total INCLUDING this upload; used_bytes is the total before it.
168
+ const usedMb = (result.storage.projected_bytes / (1024 * 1024)).toFixed(1);
169
+ const limitMb = (result.storage.limit_bytes / (1024 * 1024)).toFixed(1);
170
+ console.log(chalk.gray(`\n Storage: ${usedMb} MB / ${limitMb} MB used`));
171
+ }
172
+ console.log();
173
+ }
174
+
175
+ return result;
176
+ }
package/lib/output.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * CLI output — ONE OWNER of the "human progress vs. --json data channel" split.
3
+ *
4
+ * Relocated from bin/descix.js (WS-MEDIA-QUOTA) so lib/commands/* modules can share it
5
+ * without bin/ -> lib/ layering going backwards. This is a move, not a duplication: bin/descix.js
6
+ * imports `progress` from here instead of defining its own copy.
7
+ */
8
+
9
+ /**
10
+ * Emit human-facing PROGRESS for a command that can also emit JSON.
11
+ *
12
+ * When `--json` is set, stdout is a DATA CHANNEL: it must contain the JSON document and
13
+ * nothing else, or every scripted consumer breaks. Progress then goes to stderr, where a
14
+ * human piping stdout still sees it. Without `--json`, stdout is the human channel and
15
+ * progress belongs there.
16
+ *
17
+ * ONE OWNER of that decision. Before this existed, each --json command re-decided it by
18
+ * calling console.log directly, and both of them got it wrong: `mcp execute --json` prefixed
19
+ * the document with "Executing <tool>..." and `app media-upload --json` prefixed it with a
20
+ * header, a per-file listing and a per-file +/x line. Both exited 0, so a consumer saw a
21
+ * success code and an unparseable stream. Measured 2026-08-21/22 (seat BEAST): two seats had
22
+ * independently hand-rolled prefix-stripping workarounds (`tail -n +2`, `raw.find('[')`)
23
+ * rather than reporting it.
24
+ *
25
+ * @param {Object} options - the command's parsed options (read for .json)
26
+ * @param {...any} args - passed through to console.log / console.error
27
+ */
28
+ export function progress(options, ...args) {
29
+ if (options && options.json) console.error(...args);
30
+ else console.log(...args);
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@descix/cli",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "DeSciX CLI and MCP Server - Unified command-line interface for DeSciX platform",
5
5
  "repository": {
6
6
  "type": "git",