gipity 1.1.2 → 1.1.4

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.
@@ -31,6 +31,27 @@ function printSkippedCandidates(skipped) {
31
31
  console.log(muted(` ... and ${skipped.length - 10} more`));
32
32
  console.log(muted('Run frontend/kit module tests in the sandbox: gipity sandbox run bash "node <file>"'));
33
33
  }
34
+ /** Tests call functions over REST (`ctx.fn.call`), which resolves against the
35
+ * DEPLOYED functions — a function that's only been written locally isn't
36
+ * registered yet, so its call 404s with `Function 'name' not found`. Running
37
+ * `gipity test` right after writing a function (before deploying) is a natural
38
+ * instinct, and the bare 404 gives no hint that a deploy is the missing step.
39
+ * Detect that exact failure shape and name the fix. Returns the distinct
40
+ * function names that 404'd, or [] when no failure looks like this. */
41
+ function undeployedFunctionsFromFailures(results) {
42
+ const names = new Set();
43
+ for (const r of results) {
44
+ if (r.status !== 'failed' || !r.error)
45
+ continue;
46
+ // Shape: "Function <name> failed: 404 NOT_FOUND - Function '<name>' not found"
47
+ if (!/404\b/.test(r.error) || !/not\s+found/i.test(r.error))
48
+ continue;
49
+ const m = r.error.match(/Function ['"]?([\w-]+)['"]? (?:not found|failed)/i);
50
+ if (m)
51
+ names.add(m[1]);
52
+ }
53
+ return [...names];
54
+ }
34
55
  // Long-run hint for non-TTY runs: after this, print a one-time "not hung" + faster-path hint.
35
56
  const LONG_RUN_MS = 60000;
36
57
  async function pollTestStatus(projectGuid, runGuid, opts) {
@@ -226,6 +247,17 @@ export const testCommand = new Command('test')
226
247
  }
227
248
  console.log('');
228
249
  }
250
+ // Tests call functions against the DEPLOYED build, so a function written
251
+ // but not yet deployed 404s. That ordering (deploy → test) is invisible in
252
+ // a bare 404, so when we see that failure shape, name the fix once.
253
+ const undeployed = undeployedFunctionsFromFailures(data.results);
254
+ if (undeployed.length > 0) {
255
+ const list = undeployed.map((n) => `'${n}'`).join(', ');
256
+ console.log(warning(`${undeployed.length === 1 ? 'Function' : 'Functions'} ${list} 404'd: tests call functions against the ` +
257
+ `DEPLOYED build, and ${undeployed.length === 1 ? 'this one is' : 'these are'} not deployed yet.`));
258
+ console.log(muted('Deploy first, then re-run: gipity deploy dev && gipity test'));
259
+ console.log('');
260
+ }
229
261
  // Summary
230
262
  const parts = [];
231
263
  if (data.passed > 0)
@@ -16,6 +16,10 @@ export const updateCommand = new Command('update')
16
16
  }
17
17
  else {
18
18
  console.log(warning(`No update applied: ${result.reason}`));
19
+ if (result.reason?.startsWith('npm install')) {
20
+ console.log(dim('Full npm output: ~/.gipity/update.log'));
21
+ console.log(dim('If it keeps failing, delete ~/.gipity/local and run any gipity command to reinstall.'));
22
+ }
19
23
  }
20
24
  });
21
25
  //# sourceMappingURL=update.js.map
@@ -1,130 +1,78 @@
1
1
  import { Command } from 'commander';
2
- import { statSync, readdirSync } from 'fs';
3
- import { join, basename, posix, resolve, dirname } from 'path';
2
+ import { statSync } from 'fs';
3
+ import { basename } from 'path';
4
+ import { post } from '../api.js';
4
5
  import { resolveProjectContext } from '../config.js';
5
- import { uploadOneFile, UPLOAD_CONCURRENCY } from '../upload.js';
6
+ import { transferToS3, guessMime } from '../upload.js';
6
7
  import { formatSize } from '../utils.js';
7
- import { error as clrError, dim } from '../colors.js';
8
- /** Walk a directory recursively, returning every file's absolute path. */
9
- function walkFiles(root) {
10
- const out = [];
11
- const stack = [root];
12
- while (stack.length) {
13
- const dir = stack.pop();
14
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
15
- const full = join(dir, entry.name);
16
- if (entry.isDirectory())
17
- stack.push(full);
18
- else if (entry.isFile())
19
- out.push(full);
20
- }
21
- }
22
- return out;
23
- }
24
- /** Compose the destination virtual path for a given source file under a recursive walk. */
25
- function destFor(localFile, srcRoot, destRoot) {
26
- // POSIX-style virtual paths regardless of host OS.
27
- const rel = localFile.slice(srcRoot.length).replace(/\\/g, '/').replace(/^\/+/, '');
28
- return posix.join(destRoot.replace(/\\/g, '/').replace(/\/$/, ''), rel);
29
- }
8
+ import { success, muted, brand } from '../colors.js';
9
+ import { withSpinner } from '../progress.js';
10
+ import { run } from '../helpers/index.js';
11
+ // The app-uploads store (`/api/<guid>/uploads/*`) mints a DURABLE, worker-reachable
12
+ // URL the instant the file lands - no `gipity deploy` needed. That is the whole
13
+ // point of this command: it removes the deploy-a-fixture-then-delete-it dance an
14
+ // agent otherwise has to do to hand a job/function a real input file to fetch.
15
+ // (Uploading into the project tree with `gipity push` does NOT give a public URL
16
+ // until you deploy; this does.)
30
17
  export const uploadCommand = new Command('upload')
31
- .description('Upload files')
32
- .argument('<src>', 'Local source file or directory')
33
- .argument('[dest]', 'Destination path in the project (defaults to /)')
34
- .option('-r, --recursive', 'Upload a directory recursively')
35
- .option('--mime <type>', 'Override the content-type (default: detect from extension)')
36
- .option('--concurrency <n>', `Parallel files (default ${UPLOAD_CONCURRENCY})`)
37
- .option('--dry-run', 'Print what would be uploaded; do not call the network')
38
- .option('--project <guid-or-slug>', 'Target a specific project instead of cwd / Home')
39
- .action(async (src, destArg, opts) => {
18
+ .description(`Upload a local file and print a durable, worker-reachable URL for it.
19
+
20
+ The URL works immediately - no \`gipity deploy\` - and any job or function can
21
+ fetch it. Use it to hand a GPU/CPU job a real input file when testing end-to-end:
22
+
23
+ gipity upload song.mp3
24
+ gipity job submit split-stems --data '{"audio_url":"<printed url>"}'
25
+
26
+ By default the file is PUBLIC: a plain \`media.gipity.ai\` CDN url that resolves
27
+ from anywhere, so a cloud worker can always fetch it. Pass --private for a
28
+ token-signed serve url instead (reachable only by holders of the url).`)
29
+ .argument('<file>', 'Local file to upload')
30
+ .option('--private', 'Store as a private token-signed serve URL instead of a public CDN url')
31
+ .option('--content-type <mime>', 'Override the content type (default: detected from the file extension)')
32
+ .option('--json', 'Output as JSON')
33
+ .action((file, opts) => run('Upload', async () => {
34
+ const { config } = await resolveProjectContext();
35
+ let size;
40
36
  try {
41
- const { config } = await resolveProjectContext({ projectOverride: opts.project });
42
- const dest = destArg ?? '/';
43
- const srcStat = statSync(src);
44
- // Collect the work plan.
45
- const planned = [];
46
- if (srcStat.isDirectory()) {
47
- if (!opts.recursive) {
48
- throw new Error(`${src} is a directory - pass -r/--recursive to upload it`);
49
- }
50
- // Slice from the parent of src so the directory name itself is preserved
51
- // in the virtual path (e.g. `hooks/a.sh` → `hooks/a.sh`, not `a.sh`).
52
- const srcAbs = resolve(src);
53
- const sliceRoot = dirname(srcAbs);
54
- for (const file of walkFiles(src)) {
55
- planned.push({
56
- localPath: file,
57
- virtualPath: destFor(resolve(file), sliceRoot, dest),
58
- size: statSync(file).size,
59
- });
60
- }
61
- }
62
- else if (srcStat.isFile()) {
63
- // If dest looks like a directory (ends in / or no extension on a file path), append basename.
64
- const looksLikeDir = dest.endsWith('/') || (opts.recursive === true);
65
- const virtualPath = looksLikeDir
66
- ? posix.join(dest.replace(/\/$/, ''), basename(src))
67
- : dest;
68
- planned.push({ localPath: src, virtualPath, size: srcStat.size });
69
- }
70
- else {
71
- throw new Error(`${src} is neither a regular file nor a directory`);
72
- }
73
- if (planned.length === 0) {
74
- console.log('Nothing to upload (0 files).');
75
- return;
76
- }
77
- const totalBytes = planned.reduce((s, f) => s + f.size, 0);
78
- console.log(`Plan: ${planned.length} file${planned.length > 1 ? 's' : ''}, ${formatSize(totalBytes)}`);
79
- for (const f of planned) {
80
- console.log(`${f.localPath} ${dim('→')} ${f.virtualPath} (${formatSize(f.size)})`);
81
- }
82
- if (opts.dryRun) {
83
- console.log('\n--dry-run: skipping all network calls.');
84
- return;
85
- }
86
- const concurrency = Math.max(1, parseInt(opts.concurrency ?? String(UPLOAD_CONCURRENCY), 10));
87
- const uploadOpts = { mime: opts.mime };
88
- let cursor = 0;
89
- let uploaded = 0, skipped = 0, resumed = 0, failed = 0;
90
- const workers = [];
91
- for (let w = 0; w < Math.min(concurrency, planned.length); w++) {
92
- workers.push((async () => {
93
- while (true) {
94
- const idx = cursor++;
95
- if (idx >= planned.length)
96
- return;
97
- const f = planned[idx];
98
- try {
99
- const result = await uploadOneFile(config.projectGuid, f.localPath, f.virtualPath, uploadOpts);
100
- if (result.status === 'skipped') {
101
- skipped++;
102
- console.log(`${dim('skip')} ${f.virtualPath} (already current)`);
103
- }
104
- else if (result.status === 'resumed') {
105
- resumed++;
106
- console.log(`${dim('resumed')} ${f.virtualPath} v${result.version}`);
107
- }
108
- else {
109
- uploaded++;
110
- console.log(`${dim('uploaded')} ${f.virtualPath} v${result.version}`);
111
- }
112
- }
113
- catch (err) {
114
- failed++;
115
- console.error(clrError(` fail ${f.virtualPath}: ${err.message}`));
116
- }
117
- }
118
- })());
119
- }
120
- await Promise.all(workers);
121
- console.log(`\nUploaded: ${uploaded}, Resumed: ${resumed}, Skipped: ${skipped}, Failed: ${failed}`);
122
- if (failed > 0)
123
- process.exit(1);
37
+ const st = statSync(file);
38
+ if (!st.isFile())
39
+ throw new Error('not a regular file');
40
+ size = st.size;
124
41
  }
125
42
  catch (err) {
126
- console.error(clrError(`Upload failed: ${err.message}`));
127
- process.exit(1);
43
+ throw new Error(`can't read ${file}: ${err.message}`);
44
+ }
45
+ if (size === 0)
46
+ throw new Error(`${file} is empty - nothing to upload.`);
47
+ const filename = basename(file);
48
+ const contentType = opts.contentType || guessMime(file);
49
+ const doUpload = async () => {
50
+ const init = await post(`/api/${config.projectGuid}/uploads/init`, {
51
+ filename,
52
+ content_type: contentType,
53
+ size,
54
+ public: !opts.private,
55
+ });
56
+ const fields = await transferToS3(file, size, contentType, init.data);
57
+ const completeBody = {
58
+ upload_guid: init.data.upload_guid,
59
+ };
60
+ // Multipart completion needs the part etags; a single presigned PUT does not.
61
+ if ('parts' in fields)
62
+ completeBody.parts = fields.parts;
63
+ const comp = await post(`/api/${config.projectGuid}/uploads/complete`, completeBody);
64
+ return comp.data;
65
+ };
66
+ const data = opts.json
67
+ ? await doUpload()
68
+ : await withSpinner(`Uploading ${filename} (${formatSize(size)})…`, doUpload, { done: null });
69
+ if (opts.json) {
70
+ console.log(JSON.stringify(data));
71
+ return;
128
72
  }
129
- });
73
+ console.log(success(`Uploaded ${data.name} (${formatSize(data.size)})`));
74
+ console.log(` ${brand(data.url)}`);
75
+ console.log(muted(` Durable, worker-reachable URL - pass it straight to a job/function as an input URL.`));
76
+ console.log(muted(` ${data.is_public ? 'public (CDN)' : 'private (token-signed)'} · guid: ${data.guid}`));
77
+ }));
130
78
  //# sourceMappingURL=upload.js.map
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Resolve a JSON request/input body from the CLI.
3
+ *
4
+ * A body can be given three ways, so large payloads (images, audio, docs -
5
+ * the common case for jobs) don't have to fit in a shell argument:
6
+ * - inline JSON: '{"foo":1}'
7
+ * - a file: @path/to/body.json (reads and parses the file)
8
+ * - stdin: @- or - (reads and parses stdin)
9
+ *
10
+ * The `@file` / stdin forms exist because a base64 image easily blows past
11
+ * ARG_MAX ("Argument list too long") when passed inline. `readFileSync` on the
12
+ * path sidesteps the shell argument entirely.
13
+ */
14
+ import { readFileSync } from 'node:fs';
15
+ import { guessMime } from '../upload.js';
16
+ /** Read all of stdin synchronously (fd 0). Returns '' if stdin is a TTY. */
17
+ function readStdin() {
18
+ if (process.stdin.isTTY)
19
+ return '';
20
+ try {
21
+ return readFileSync(0, 'utf-8');
22
+ }
23
+ catch {
24
+ return '';
25
+ }
26
+ }
27
+ /**
28
+ * Read a `--file field=@path` (or `field=path`) spec and return
29
+ * `[field, { data, media_type }]`. This is the ergonomic path for calling an
30
+ * image / audio / document function end-to-end: instead of a manual
31
+ * `base64 | grep | python json.dumps` dance to stuff a photo into a JSON body,
32
+ * the CLI reads the file itself, base64-encodes it, and wraps it in the
33
+ * `{ data, media_type }` envelope every Gipity vision/media service and the
34
+ * browser SDK expect (see the `image` field in app-llm). A bare base64 string
35
+ * would mismatch that shape, so attachments always carry their media type
36
+ * (detected from the file extension). The leading `@` is optional (accepted
37
+ * for symmetry with `--data @file`).
38
+ */
39
+ export function readFileField(spec) {
40
+ const eq = spec.indexOf('=');
41
+ if (eq === -1) {
42
+ throw new Error(`Invalid --file '${spec}': expected field=@path (e.g. --file image=@receipt.png).`);
43
+ }
44
+ const field = spec.slice(0, eq).trim();
45
+ let path = spec.slice(eq + 1);
46
+ if (path.startsWith('@'))
47
+ path = path.slice(1);
48
+ if (!field)
49
+ throw new Error(`Invalid --file '${spec}': missing field name before '='.`);
50
+ if (!path)
51
+ throw new Error(`Invalid --file '${spec}': missing file path after '='.`);
52
+ try {
53
+ return [field, { data: readFileSync(path).toString('base64'), media_type: guessMime(path) }];
54
+ }
55
+ catch (e) {
56
+ throw new Error(`Cannot read --file '${path}': ${e.message}`);
57
+ }
58
+ }
59
+ /**
60
+ * Resolve a JSON body and merge in any `--file field=@path` attachments, each
61
+ * attached as `{ data, media_type }` under its field. `fileSpecs` is the
62
+ * repeatable `--file` list. File attachments require an object body (they can't
63
+ * merge into an array or scalar), which is the normal case for a function/job
64
+ * request.
65
+ */
66
+ export function resolveBody(raw, fileSpecs) {
67
+ const body = resolveJsonBody(raw);
68
+ if (!fileSpecs || fileSpecs.length === 0)
69
+ return body;
70
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
71
+ throw new Error('--file needs an object body to attach into (got a non-object JSON body).');
72
+ }
73
+ const obj = body;
74
+ for (const spec of fileSpecs) {
75
+ const [field, attachment] = readFileField(spec);
76
+ obj[field] = attachment;
77
+ }
78
+ return obj;
79
+ }
80
+ /**
81
+ * Turn a raw body argument into a parsed JSON value.
82
+ * `raw` is the positional body or the `--data` value; undefined => `{}`.
83
+ */
84
+ export function resolveJsonBody(raw) {
85
+ if (raw == null || raw === '')
86
+ return {};
87
+ let source;
88
+ let origin;
89
+ if (raw === '-' || raw === '@-') {
90
+ source = readStdin();
91
+ origin = 'stdin';
92
+ if (source.trim() === '') {
93
+ throw new Error('No JSON on stdin (pipe a body in, e.g. `cat body.json | gipity fn call foo -d -`).');
94
+ }
95
+ }
96
+ else if (raw.startsWith('@')) {
97
+ const path = raw.slice(1);
98
+ try {
99
+ source = readFileSync(path, 'utf-8');
100
+ }
101
+ catch (e) {
102
+ throw new Error(`Cannot read body file '${path}': ${e.message}`);
103
+ }
104
+ origin = `file '${path}'`;
105
+ }
106
+ else {
107
+ source = raw;
108
+ origin = 'inline JSON';
109
+ }
110
+ try {
111
+ return JSON.parse(source);
112
+ }
113
+ catch (e) {
114
+ throw new Error(`Invalid JSON in ${origin}: ${e.message}`);
115
+ }
116
+ }
117
+ //# sourceMappingURL=body.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Duration parsing shared by the sibling `--timeout` flags.
3
+ *
4
+ * `sandbox run --timeout` is native SECONDS and `page eval --timeout` is native
5
+ * MILLISECONDS — the same flag name, two units, which reliably trips an agent
6
+ * carrying a value from one command to the other. The reconciliation is an
7
+ * explicit unit suffix that means the SAME thing everywhere: `--timeout 90s` is
8
+ * 90 seconds on both, `--timeout 1500ms` is 1.5s on both. A bare number keeps
9
+ * each command's native unit (so nothing about existing invocations changes),
10
+ * but the portable, unambiguous form now works and the help teaches it.
11
+ */
12
+ const UNIT_TO_MS = { ms: 1, s: 1_000, m: 60_000 };
13
+ /** Parse a duration that MAY carry a `ms`/`s`/`m` suffix, returning it in `unit`
14
+ * (the caller's native unit). A bare number is taken as already being in `unit`
15
+ * and returned unchanged with `hadSuffix:false`, so the caller's own bare-number
16
+ * handling (defaults, unit-mixup guards) still runs. Returns null only when the
17
+ * input is not a number at all — the caller then falls back to its prior parse. */
18
+ export function parseDuration(raw, unit) {
19
+ if (raw === undefined)
20
+ return null;
21
+ const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m)?$/i);
22
+ if (!m)
23
+ return null;
24
+ const n = parseFloat(m[1]);
25
+ if (!Number.isFinite(n))
26
+ return null;
27
+ const suffix = m[2]?.toLowerCase();
28
+ if (!suffix)
29
+ return { value: n, hadSuffix: false };
30
+ const ms = n * UNIT_TO_MS[suffix];
31
+ const value = unit === 'ms' ? ms : ms / 1_000;
32
+ return { value, hadSuffix: true };
33
+ }
34
+ //# sourceMappingURL=duration.js.map
@@ -4,4 +4,6 @@
4
4
  export { run } from './command.js';
5
5
  export { printOutput, printList, printResult, pluckField, emitField } from './output.js';
6
6
  export { syncBeforeAction } from './sync.js';
7
+ export { resolveJsonBody, resolveBody } from './body.js';
8
+ export { parseDuration } from './duration.js';
7
9
  //# sourceMappingURL=index.js.map