gipity 1.1.3 → 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.
- package/dist/catalog.js +1 -3
- package/dist/commands/build.js +99 -29
- package/dist/commands/deploy.js +6 -2
- package/dist/commands/file.js +18 -0
- package/dist/commands/fn.js +6 -6
- package/dist/commands/generate.js +76 -12
- package/dist/commands/job.js +125 -8
- package/dist/commands/page-eval.js +79 -10
- package/dist/commands/page-inspect.js +26 -1
- package/dist/commands/page-screenshot.js +33 -4
- package/dist/commands/records.js +14 -1
- package/dist/commands/sandbox.js +181 -9
- package/dist/commands/service.js +6 -1
- package/dist/commands/test.js +32 -0
- package/dist/commands/update.js +4 -0
- package/dist/commands/upload.js +69 -121
- package/dist/helpers/body.js +117 -0
- package/dist/helpers/duration.js +34 -0
- package/dist/helpers/index.js +2 -0
- package/dist/index.js +848 -375
- package/dist/knowledge.js +4 -7
- package/dist/updater/bootstrap.js +27 -16
- package/dist/updater/check.js +86 -10
- package/dist/updater/install.js +82 -0
- package/dist/updater/shim.js +90 -27
- package/package.json +2 -2
package/dist/catalog.js
CHANGED
|
@@ -36,11 +36,9 @@ export const KITS = [
|
|
|
36
36
|
{ key: 'chatbot', hint: 'drop-in chatbot - persona, guardrails, streaming responses' },
|
|
37
37
|
{ key: 'audio-align', hint: 'audio + lyrics -> word-level timing JSON (GPU job)' },
|
|
38
38
|
{ key: 'i18n', hint: 'multi-language web apps - language picker, RTL, translations' },
|
|
39
|
-
{ key: 'records', hint: 'registry-driven data plane - generic CRUD, validation, search, audit spine' },
|
|
40
|
-
{ key: 'views', hint: 'registry-driven UI over records - table, forms, kanban' },
|
|
41
|
-
{ key: 'agent-api', hint: 'named API keys for agent/script writes through the records kit' },
|
|
42
39
|
{ key: 'contacts', hint: 'multi-source contact layer - LinkedIn/Gmail import, dedupe with provenance' },
|
|
43
40
|
{ key: 'stripe', hint: 'charge your users - Stripe checkout, subscriptions, brokered webhooks' },
|
|
44
41
|
{ key: 'notify', hint: 'web push notifications - platform-owned keys, works on iOS home screen' },
|
|
42
|
+
{ key: 'servicenow', hint: 'ServiceNow tables as a data source - OAuth pull/write-back/real-time sync' },
|
|
45
43
|
];
|
|
46
44
|
//# sourceMappingURL=catalog.js.map
|
package/dist/commands/build.js
CHANGED
|
@@ -1171,37 +1171,47 @@ async function pickOrCreateProject(projects, existingSlugs) {
|
|
|
1171
1171
|
const showAdopt = canAdoptCwd(cwd);
|
|
1172
1172
|
// Reserve slot 1 for "create new", slot 2 for "use this dir" when shown.
|
|
1173
1173
|
const reserved = showAdopt ? 2 : 1;
|
|
1174
|
-
// Pickable single-keypress range is 1-9
|
|
1175
|
-
//
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1174
|
+
// Pickable single-keypress range is 1-9. When every project fits, list
|
|
1175
|
+
// them all; otherwise the actions grow a "Search projects" slot right
|
|
1176
|
+
// after the reserved ones and "Show more" takes slot 9 - accounts with
|
|
1177
|
+
// hundreds of projects can't scroll a dump. Action rows render in a
|
|
1178
|
+
// different color than project rows so the two groups read apart.
|
|
1179
|
+
const fitsAll = filtered.length <= 9 - reserved;
|
|
1180
|
+
const hasMore = !fitsAll;
|
|
1181
|
+
const searchSlot = hasMore ? reserved + 1 : 0;
|
|
1182
|
+
const projectBase = reserved + (hasMore ? 1 : 0); // slots projectBase+1.. are projects
|
|
1183
|
+
const recent = fitsAll ? filtered : filtered.slice(0, 9 - reserved - 2);
|
|
1184
|
+
const moreSlot = hasMore ? projectBase + recent.length + 1 : 0; // always 9 when shown
|
|
1179
1185
|
// Loop so that a refused/declined adopt-cwd re-shows the picker rather
|
|
1180
1186
|
// than dropping the user into a shell.
|
|
1181
1187
|
while (true) {
|
|
1182
1188
|
const newProjectLabel = formatNewProjectLabel(existingSlugs);
|
|
1183
1189
|
console.log(` ${bold('Choose project to open:')}\n`);
|
|
1184
|
-
console.log(` ${bold('1.')} Create new project ${muted(`(${newProjectLabel})`)}`);
|
|
1190
|
+
console.log(` ${bold('1.')} ${info('Create new project')} ${muted(`(${newProjectLabel})`)}`);
|
|
1185
1191
|
if (showAdopt) {
|
|
1186
|
-
console.log(` ${bold('2.')} Use this directory ${muted(`(${formatCwdLabel(cwd)})`)}`);
|
|
1192
|
+
console.log(` ${bold('2.')} ${info('Use this directory')} ${muted(`(${formatCwdLabel(cwd)})`)}`);
|
|
1187
1193
|
}
|
|
1188
|
-
recent.forEach((p, i) => console.log(` ${bold(`${i + reserved + 1}.`)} ${p.name} ${muted(`(${p.slug})`)}`));
|
|
1189
1194
|
if (hasMore) {
|
|
1190
|
-
console.log(` ${bold(`${
|
|
1195
|
+
console.log(` ${bold(`${searchSlot}.`)} ${info('Search projects')} ${muted(`(${filtered.length} total)`)}`);
|
|
1196
|
+
}
|
|
1197
|
+
recent.forEach((p, i) => console.log(` ${bold(`${projectBase + i + 1}.`)} ${p.name} ${muted(`(${p.slug})`)}`));
|
|
1198
|
+
if (hasMore) {
|
|
1199
|
+
console.log(` ${bold(`${moreSlot}.`)} ${info('Show more')}`);
|
|
1191
1200
|
}
|
|
1192
1201
|
console.log('');
|
|
1193
|
-
const maxOption = recent.length +
|
|
1202
|
+
const maxOption = projectBase + recent.length + (hasMore ? 1 : 0);
|
|
1194
1203
|
const idx = await pickOne('Choose', maxOption, 1);
|
|
1195
|
-
// Recent project (slots
|
|
1196
|
-
if (idx >
|
|
1197
|
-
return { kind: 'pick', project: recent[idx -
|
|
1204
|
+
// Recent project (slots projectBase+1 .. projectBase+recent.length).
|
|
1205
|
+
if (idx > projectBase && idx <= projectBase + recent.length) {
|
|
1206
|
+
return { kind: 'pick', project: recent[idx - projectBase - 1] };
|
|
1198
1207
|
}
|
|
1199
|
-
//
|
|
1200
|
-
if (hasMore && idx ===
|
|
1201
|
-
const picked = await
|
|
1208
|
+
// Search (right after the reserved slots) / Show more (slot 9).
|
|
1209
|
+
if (hasMore && (idx === searchSlot || idx === moreSlot)) {
|
|
1210
|
+
const picked = await browseProjects(filtered, { search: idx === searchSlot });
|
|
1202
1211
|
if (picked)
|
|
1203
1212
|
return { kind: 'pick', project: picked };
|
|
1204
|
-
|
|
1213
|
+
console.log('');
|
|
1214
|
+
continue; // user backed out; re-show top picker
|
|
1205
1215
|
}
|
|
1206
1216
|
// Slot 2 = "Use this directory" (only when shown).
|
|
1207
1217
|
if (showAdopt && idx === 2) {
|
|
@@ -1217,19 +1227,79 @@ async function pickOrCreateProject(projects, existingSlugs) {
|
|
|
1217
1227
|
return { kind: 'create-new', project };
|
|
1218
1228
|
}
|
|
1219
1229
|
}
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1230
|
+
const PROJECT_PAGE_SIZE = 10;
|
|
1231
|
+
/** Case-insensitive substring match on project name or slug. An empty or
|
|
1232
|
+
* whitespace-only query matches everything. Exported for tests. */
|
|
1233
|
+
export function searchProjects(all, query) {
|
|
1234
|
+
const q = query.trim().toLowerCase();
|
|
1235
|
+
if (!q)
|
|
1236
|
+
return all;
|
|
1237
|
+
return all.filter(p => p.name.toLowerCase().includes(q) || p.slug.toLowerCase().includes(q));
|
|
1238
|
+
}
|
|
1239
|
+
/** Paged, searchable project list - the shared renderer behind both the
|
|
1240
|
+
* "Search projects" and "Show more" picker slots. Shows PROJECT_PAGE_SIZE
|
|
1241
|
+
* rows at a time with numbering that stays stable across pages, then reads
|
|
1242
|
+
* a full line (not single-keypress - numbers can exceed 9):
|
|
1243
|
+
* Enter → next page · <number> → open that project
|
|
1244
|
+
* s → new search (always over the FULL list, never the current subset)
|
|
1245
|
+
* q → back to the main picker (returns null).
|
|
1246
|
+
* `opts.search` starts by prompting for a query instead of listing all.
|
|
1247
|
+
* Exported for tests. */
|
|
1248
|
+
export async function browseProjects(all, opts = {}) {
|
|
1249
|
+
let items = all;
|
|
1250
|
+
let heading = 'All projects';
|
|
1251
|
+
// Prompt for a query and swap the working list to the matches. Returns
|
|
1252
|
+
// false (list unchanged) on empty input or zero matches.
|
|
1253
|
+
const runSearch = async () => {
|
|
1254
|
+
const q = await prompt(` ${bold('Search projects:')} `);
|
|
1255
|
+
if (!q)
|
|
1256
|
+
return false;
|
|
1257
|
+
const matches = searchProjects(all, q);
|
|
1258
|
+
if (matches.length === 0) {
|
|
1259
|
+
console.log(` ${muted(`No projects match "${q}".`)}`);
|
|
1260
|
+
return false;
|
|
1261
|
+
}
|
|
1262
|
+
items = matches;
|
|
1263
|
+
heading = `Projects matching "${q}"`;
|
|
1264
|
+
return true;
|
|
1265
|
+
};
|
|
1227
1266
|
console.log('');
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1267
|
+
if (opts.search && !(await runSearch()))
|
|
1268
|
+
return null;
|
|
1269
|
+
let shown = 0;
|
|
1270
|
+
while (true) {
|
|
1271
|
+
if (shown < items.length) {
|
|
1272
|
+
const page = items.slice(shown, shown + PROJECT_PAGE_SIZE);
|
|
1273
|
+
if (shown === 0)
|
|
1274
|
+
console.log(` ${bold(`${heading}`)} ${muted(`(${items.length})`)}\n`);
|
|
1275
|
+
page.forEach((p, i) => console.log(` ${bold(`${String(shown + i + 1).padStart(3)}.`)} ${p.name} ${muted(`(${p.slug})`)}`));
|
|
1276
|
+
shown += page.length;
|
|
1277
|
+
console.log('');
|
|
1278
|
+
}
|
|
1279
|
+
const more = shown < items.length;
|
|
1280
|
+
const hints = [
|
|
1281
|
+
more ? `Enter = next ${Math.min(PROJECT_PAGE_SIZE, items.length - shown)}` : null,
|
|
1282
|
+
'number = open',
|
|
1283
|
+
's = search',
|
|
1284
|
+
'q = back',
|
|
1285
|
+
].filter(Boolean).join(' · ');
|
|
1286
|
+
const answer = (await prompt(` ${bold('Choose')} ${muted(`(${hints})`)}: `)).toLowerCase();
|
|
1287
|
+
if (answer === '' && more)
|
|
1288
|
+
continue; // Enter → next page
|
|
1289
|
+
if (answer === '' || answer === 'q')
|
|
1290
|
+
return null;
|
|
1291
|
+
if (answer === 's') {
|
|
1292
|
+
if (await runSearch()) {
|
|
1293
|
+
shown = 0;
|
|
1294
|
+
console.log('');
|
|
1295
|
+
}
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
const n = parseInt(answer, 10);
|
|
1299
|
+
if (Number.isInteger(n) && n >= 1 && n <= shown)
|
|
1300
|
+
return items[n - 1];
|
|
1301
|
+
console.log(` ${muted('Type one of the numbers above, Enter for more, s to search, or q to go back.')}`);
|
|
1302
|
+
}
|
|
1233
1303
|
}
|
|
1234
1304
|
/** Show "(~/GipityProjects/project-NNN)" - the exact dir option 1 will
|
|
1235
1305
|
* create, so the user can see where their new project will land. */
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { post } from '../api.js';
|
|
3
|
-
import { requireConfig } from '../config.js';
|
|
3
|
+
import { requireConfig, resolveApiBase } from '../config.js';
|
|
4
4
|
import { formatSize } from '../utils.js';
|
|
5
5
|
import { success, error as clrError, warning, muted, bold, brand } from '../colors.js';
|
|
6
6
|
import { run, syncBeforeAction } from '../helpers/index.js';
|
|
@@ -150,7 +150,11 @@ export const deployCommand = new Command('deploy')
|
|
|
150
150
|
// for an API-only app the endpoint address IS the deliverable, so name
|
|
151
151
|
// it instead of leaving it to be inferred from a template comment.
|
|
152
152
|
if (d.phases?.some(p => p.name === 'functions')) {
|
|
153
|
-
|
|
153
|
+
// Print the base the CLI actually calls (resolveApiBase honors the
|
|
154
|
+
// --api-base flag and GIPITY_API_BASE env), not the raw stored
|
|
155
|
+
// config.apiBase - otherwise on any override we'd advertise a host we
|
|
156
|
+
// didn't deploy to and send the caller probing the wrong instance.
|
|
157
|
+
console.log(`${muted('Functions:')} POST ${brand(`${resolveApiBase()}/api/${config.projectGuid}/fn/<name>`)}`);
|
|
154
158
|
console.log(muted(' (same address on dev and prod; names: gipity fn list; verify the public path: gipity fn call <name> --anon)'));
|
|
155
159
|
}
|
|
156
160
|
await inspectAfter();
|
package/dist/commands/file.js
CHANGED
|
@@ -34,6 +34,24 @@ fileCommand
|
|
|
34
34
|
process.stdout.write(res.data.content);
|
|
35
35
|
}
|
|
36
36
|
}));
|
|
37
|
+
fileCommand
|
|
38
|
+
.command('url <path>')
|
|
39
|
+
.description('Durable public URL for a project file - no deploy needed. Reachable by browsers, GPU jobs, and external services the moment the file syncs; ideal for throwaway test fixtures (delete the file to revoke the link).')
|
|
40
|
+
.option('--download', 'URL forces a download (Content-Disposition: attachment)')
|
|
41
|
+
.option('--json', 'Output as JSON')
|
|
42
|
+
.action((path, opts) => run('Url', async () => {
|
|
43
|
+
const { config } = await resolveProjectContext();
|
|
44
|
+
const qs = new URLSearchParams({ path });
|
|
45
|
+
if (opts.download)
|
|
46
|
+
qs.set('download', '1');
|
|
47
|
+
const res = await get(`/projects/${config.projectGuid}/files/url?${qs.toString()}`);
|
|
48
|
+
if (opts.json) {
|
|
49
|
+
console.log(JSON.stringify(res.data));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.log(res.data.url);
|
|
53
|
+
}
|
|
54
|
+
}));
|
|
37
55
|
fileCommand
|
|
38
56
|
.command('tree [path]')
|
|
39
57
|
.description('Show the file tree')
|
package/dist/commands/fn.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { get, post, del, publicPost } from '../api.js';
|
|
3
3
|
import { getAuth } from '../auth.js';
|
|
4
|
-
import { requireConfig } from '../config.js';
|
|
4
|
+
import { requireConfig, resolveApiBase } from '../config.js';
|
|
5
5
|
import { error as clrError, bold, muted, success, warning } from '../colors.js';
|
|
6
|
-
import { run, printList, emitField } from '../helpers/index.js';
|
|
6
|
+
import { run, printList, emitField, resolveBody } from '../helpers/index.js';
|
|
7
7
|
import { confirm } from '../utils.js';
|
|
8
8
|
export const fnCommand = new Command('fn')
|
|
9
9
|
.description('Manage functions');
|
|
@@ -21,7 +21,7 @@ fnCommand
|
|
|
21
21
|
// The callable address is the deliverable for API-only apps - name it here
|
|
22
22
|
// rather than leaving it to be inferred from a template comment.
|
|
23
23
|
if (!opts.json && res.data.length > 0) {
|
|
24
|
-
console.log(muted(`Endpoint: POST ${
|
|
24
|
+
console.log(muted(`Endpoint: POST ${resolveApiBase()}/api/${config.projectGuid}/fn/<name> (same on dev and prod; public path: gipity fn call <name> --anon)`));
|
|
25
25
|
}
|
|
26
26
|
}));
|
|
27
27
|
fnCommand
|
|
@@ -73,14 +73,14 @@ async function callAnon(projectGuid, name, body) {
|
|
|
73
73
|
fnCommand
|
|
74
74
|
.command('call <name> [body]')
|
|
75
75
|
.description('Call a function')
|
|
76
|
-
.option('--data <json>', 'JSON request body')
|
|
76
|
+
.option('-d, --data <json>', 'JSON request body: inline JSON, @file to read a file, or @- / - for stdin')
|
|
77
|
+
.option('--file <field=@path>', 'Attach a file as { data, media_type } under <field> (the vision/media service shape), repeatable, e.g. --file image=@receipt.png', (v, acc) => (acc || []).concat(v))
|
|
77
78
|
.option('--anon', 'Call as an anonymous visitor (the public path a signed-out user hits) instead of as your signed-in account')
|
|
78
79
|
.option('--field <path>', 'Print only this field of the result (dot path, e.g. items.0.short_guid)')
|
|
79
80
|
.option('--json', 'Output as JSON')
|
|
80
81
|
.action((name, bodyArg, opts) => run('Call', async () => {
|
|
81
82
|
const config = requireConfig();
|
|
82
|
-
const
|
|
83
|
-
const body = JSON.parse(raw);
|
|
83
|
+
const body = resolveBody(bodyArg || opts.data, opts.file);
|
|
84
84
|
const path = `/api/${config.projectGuid}/fn/${encodeURIComponent(name)}`;
|
|
85
85
|
// Name the calling identity (stderr, so stdout stays parseable). Without
|
|
86
86
|
// this, a "test the anonymous path" call silently runs authenticated as
|
|
@@ -2,11 +2,12 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { post } from '../api.js';
|
|
3
3
|
import { resolveProjectContext, getConfigPath } from '../config.js';
|
|
4
4
|
import { pushFile } from '../sync.js';
|
|
5
|
-
import { mkdirSync, writeFileSync } from 'fs';
|
|
6
|
-
import { resolve as resolvePath, dirname, relative, isAbsolute } from 'path';
|
|
5
|
+
import { mkdirSync, writeFileSync, readFileSync } from 'fs';
|
|
6
|
+
import { resolve as resolvePath, dirname, relative, isAbsolute, basename } from 'path';
|
|
7
7
|
import { error as clrError, success, muted } from '../colors.js';
|
|
8
8
|
import { printCommandError } from '../helpers/command.js';
|
|
9
9
|
import { withSpinner } from '../progress.js';
|
|
10
|
+
import { guessMime } from '../upload.js';
|
|
10
11
|
import { IMAGE_MODELS_DOC, IMAGE_GEMINI_ASPECT_RATIOS, IMAGE_GEMINI_SIZES, VIDEO_MODELS_DOC, TTS_PROVIDER_DESCRIPTIONS } from '../provider-docs.js';
|
|
11
12
|
/** Download a URL and save to a local file, then push it up to the project so
|
|
12
13
|
* the cloud (and anything that mirrors it) immediately matches local disk.
|
|
@@ -20,12 +21,25 @@ import { IMAGE_MODELS_DOC, IMAGE_GEMINI_ASPECT_RATIOS, IMAGE_GEMINI_SIZES, VIDEO
|
|
|
20
21
|
* that gap so the "sandbox auto-mirrors the project" contract holds right after
|
|
21
22
|
* generation. Best-effort: the local save already succeeded, so a push failure
|
|
22
23
|
* only warns and points at `gipity sync`. */
|
|
23
|
-
async function downloadFile(url, filename) {
|
|
24
|
+
async function downloadFile(url, filename, explicit) {
|
|
24
25
|
const res = await fetch(url);
|
|
25
26
|
if (!res.ok)
|
|
26
27
|
throw new Error(`Download failed: ${res.status}`);
|
|
27
28
|
const buffer = Buffer.from(await res.arrayBuffer());
|
|
28
|
-
|
|
29
|
+
// An explicit `-o` path is a name the caller has committed to and will chain
|
|
30
|
+
// the NEXT command against (`--input src.png`, `page eval --camera src.png`),
|
|
31
|
+
// so the saved path MUST equal what was asked - a predictable path is worth
|
|
32
|
+
// more than a tidy extension. The model's output format is non-deterministic
|
|
33
|
+
// (BFL's "png" request comes back as JPEG), so renaming to match the bytes
|
|
34
|
+
// moves the file out from under the caller's already-written second command:
|
|
35
|
+
// that is the "saved as .jpg, retry with the right path" wasted turn. Honour
|
|
36
|
+
// the request; every downstream consumer (browsers, image/video tools, the
|
|
37
|
+
// edit + camera services) sniffs the bytes, not the extension, so a jpg-in-a-
|
|
38
|
+
// .png plays and edits fine. For the auto-generated default there is no
|
|
39
|
+
// committed path, so there we DO name the file after its real bytes.
|
|
40
|
+
const outPath = explicit ? filename : correctExtension(filename, buffer);
|
|
41
|
+
if (explicit)
|
|
42
|
+
noteFormatMismatch(filename, buffer);
|
|
29
43
|
ensureOutputDir(outPath);
|
|
30
44
|
writeFileSync(outPath, buffer);
|
|
31
45
|
const savedPath = resolvePath(outPath);
|
|
@@ -85,6 +99,23 @@ function correctExtension(filename, buf) {
|
|
|
85
99
|
+ `saving as ${corrected} so the extension matches the actual bytes.`));
|
|
86
100
|
return corrected;
|
|
87
101
|
}
|
|
102
|
+
/** When the caller committed to an explicit `-o` path we save to it verbatim
|
|
103
|
+
* (see downloadFile), but the model's format may not match the extension. Say
|
|
104
|
+
* so, and say plainly that the file is at the requested path — so an agent that
|
|
105
|
+
* reads this note does NOT go change the path its already-queued next command
|
|
106
|
+
* points at (that "retry with .jpg" turn is the whole bug we're closing). */
|
|
107
|
+
function noteFormatMismatch(filename, buf) {
|
|
108
|
+
const actual = sniffExt(buf);
|
|
109
|
+
if (!actual)
|
|
110
|
+
return;
|
|
111
|
+
const dot = filename.lastIndexOf('.');
|
|
112
|
+
const asked = dot > 0 ? filename.slice(dot + 1).toLowerCase() : '';
|
|
113
|
+
if (!asked || canonicalExt(asked) === actual)
|
|
114
|
+
return;
|
|
115
|
+
console.error(muted(`Note: ${basename(filename)} holds ${formatName(actual)} bytes though its name ends .${asked} — `
|
|
116
|
+
+ `that's fine (browsers and image/video/edit tools sniff the bytes, not the name). `
|
|
117
|
+
+ `Saved at the exact path you requested; reference it as-is.`));
|
|
118
|
+
}
|
|
88
119
|
/** Create the parent directory of an -o path, so `-o src/audio/ding.mp3` works
|
|
89
120
|
* in a tree that has no `src/audio/` yet instead of dying on a raw ENOENT.
|
|
90
121
|
*
|
|
@@ -116,22 +147,52 @@ async function pushGenerated(savedPath) {
|
|
|
116
147
|
console.error(muted(`Note: couldn't sync to the cloud automatically (${err.message}). Run \`gipity sync\` before referencing this file in \`gipity sandbox run\`.`));
|
|
117
148
|
}
|
|
118
149
|
}
|
|
150
|
+
/** Read the `--input` image files an edit request is applied to, as base64 +
|
|
151
|
+
* mime type. The CLI reads the bytes itself so a photo never has to travel
|
|
152
|
+
* through the shell argv (base64 images blow past ARG_MAX — "Argument list too
|
|
153
|
+
* long" — the moment you try to inline them), which is exactly the trap an
|
|
154
|
+
* agent falls into hand-rolling an edit call with a python one-liner. */
|
|
155
|
+
function readInputImages(paths) {
|
|
156
|
+
return paths.map((p) => {
|
|
157
|
+
let buf;
|
|
158
|
+
try {
|
|
159
|
+
buf = readFileSync(p);
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
throw new Error(`can't read --input image ${p} (${e.code || e.message}).`);
|
|
163
|
+
}
|
|
164
|
+
const mime = guessMime(p);
|
|
165
|
+
if (!mime.startsWith('image/')) {
|
|
166
|
+
throw new Error(`--input ${p} is not an image (${mime}). Edit inputs must be image files.`);
|
|
167
|
+
}
|
|
168
|
+
return { data: buf.toString('base64'), mime_type: mime };
|
|
169
|
+
});
|
|
170
|
+
}
|
|
119
171
|
// ── IMAGE ──────────────────────────────────────────────────────────────
|
|
120
172
|
const imageCommand = new Command('image')
|
|
121
|
-
.description(`Generate an image from a text prompt
|
|
173
|
+
.description(`Generate an image from a text prompt, or EDIT existing images with --input.
|
|
122
174
|
|
|
123
175
|
Models: ${IMAGE_MODELS_DOC}
|
|
124
176
|
|
|
177
|
+
Editing (--input): pass one or more source images and the prompt becomes an edit
|
|
178
|
+
instruction applied to them - "make it night time", "add a hat", "remove the car
|
|
179
|
+
in the background", or compose several inputs together. Editing routes to Gemini
|
|
180
|
+
automatically. This is the first-party way to exercise the image-edit flow from
|
|
181
|
+
the CLI; the CLI reads the file bytes itself, so there is no base64/argv fiddling.
|
|
182
|
+
|
|
125
183
|
Gemini-specific options:
|
|
126
184
|
--aspect-ratio Control output shape: ${IMAGE_GEMINI_ASPECT_RATIOS}
|
|
127
185
|
--image-size Control resolution: ${IMAGE_GEMINI_SIZES} (default: 1K)
|
|
128
186
|
|
|
129
187
|
Examples:
|
|
130
188
|
gipity generate image "a cat wearing a top hat"
|
|
189
|
+
gipity generate image "make it night time" --input photo.jpg -o edited.png
|
|
190
|
+
gipity generate image "put the person in the first photo into the second scene" --input person.png --input scene.png
|
|
131
191
|
gipity generate image "landscape sunset" --provider gemini --aspect-ratio 16:9 --image-size 2K
|
|
132
192
|
gipity generate image "product photo" --provider openai --model gpt-image-2 --size 1536x1024 --quality high
|
|
133
193
|
gipity generate image "abstract art" --provider bfl --model flux-2-pro -o art.png`)
|
|
134
|
-
.argument('<prompt>', 'Text description of the image to
|
|
194
|
+
.argument('<prompt>', 'Text description of the image, or (with --input) the edit instruction to apply')
|
|
195
|
+
.option('--input <file>', 'Source image to edit/compose (repeatable). With --input the prompt is an edit instruction; routes to Gemini.', (v, acc) => (acc || []).concat(v))
|
|
135
196
|
.option('--provider <provider>', 'Image provider: openai, bfl, or gemini (default: bfl)')
|
|
136
197
|
.option('--model <model>', 'Model ID (see provider list above)')
|
|
137
198
|
.option('--size <size>', 'Dimensions as WxH, e.g. "1024x1024" (OpenAI/BFL)')
|
|
@@ -146,6 +207,7 @@ Examples:
|
|
|
146
207
|
const { config } = await resolveProjectContext();
|
|
147
208
|
if (opts.output)
|
|
148
209
|
ensureOutputDir(opts.output);
|
|
210
|
+
const inputImages = opts.input ? readInputImages(opts.input) : undefined;
|
|
149
211
|
const doGenerate = () => post(`/projects/${config.projectGuid}/generate/image`, {
|
|
150
212
|
prompt,
|
|
151
213
|
provider: opts.provider,
|
|
@@ -155,13 +217,15 @@ Examples:
|
|
|
155
217
|
aspect_ratio: opts.aspectRatio,
|
|
156
218
|
image_size: opts.imageSize,
|
|
157
219
|
seed: Number.isFinite(opts.seed) ? opts.seed : undefined,
|
|
220
|
+
input_images: inputImages,
|
|
158
221
|
});
|
|
222
|
+
const verb = inputImages ? 'Editing image…' : 'Generating image…';
|
|
159
223
|
const result = opts.json
|
|
160
224
|
? await doGenerate()
|
|
161
|
-
: await withSpinner(
|
|
225
|
+
: await withSpinner(verb, doGenerate, { done: null });
|
|
162
226
|
const ext = result.content_type.includes('png') ? 'png' : 'jpg';
|
|
163
227
|
const filename = opts.output || `generated.${ext}`;
|
|
164
|
-
const savedPath = await downloadFile(result.url, filename);
|
|
228
|
+
const savedPath = await downloadFile(result.url, filename, !!opts.output);
|
|
165
229
|
if (opts.json) {
|
|
166
230
|
console.log(JSON.stringify({ ...result, saved: savedPath }));
|
|
167
231
|
}
|
|
@@ -223,7 +287,7 @@ Examples:
|
|
|
223
287
|
? await doGenerate()
|
|
224
288
|
: await withSpinner('Generating video…', doGenerate, { done: null });
|
|
225
289
|
const filename = opts.output || 'generated.mp4';
|
|
226
|
-
const savedPath = await downloadFile(result.url, filename);
|
|
290
|
+
const savedPath = await downloadFile(result.url, filename, !!opts.output);
|
|
227
291
|
if (opts.json) {
|
|
228
292
|
console.log(JSON.stringify({ ...result, saved: savedPath }));
|
|
229
293
|
}
|
|
@@ -287,7 +351,7 @@ Examples:
|
|
|
287
351
|
? await doGenerate()
|
|
288
352
|
: await withSpinner('Generating speech…', doGenerate, { done: null });
|
|
289
353
|
const filename = opts.output || 'speech.mp3';
|
|
290
|
-
const savedPath = await downloadFile(result.url, filename);
|
|
354
|
+
const savedPath = await downloadFile(result.url, filename, !!opts.output);
|
|
291
355
|
if (opts.json) {
|
|
292
356
|
console.log(JSON.stringify({ ...result, saved: savedPath }));
|
|
293
357
|
}
|
|
@@ -340,7 +404,7 @@ Examples:
|
|
|
340
404
|
? await doGenerate()
|
|
341
405
|
: await withSpinner('Generating music…', doGenerate, { done: null });
|
|
342
406
|
const filename = opts.output || 'music.mp3';
|
|
343
|
-
const savedPath = await downloadFile(result.url, filename);
|
|
407
|
+
const savedPath = await downloadFile(result.url, filename, !!opts.output);
|
|
344
408
|
if (opts.json) {
|
|
345
409
|
console.log(JSON.stringify({ ...result, saved: savedPath }));
|
|
346
410
|
}
|
|
@@ -391,7 +455,7 @@ Examples:
|
|
|
391
455
|
? await doGenerate()
|
|
392
456
|
: await withSpinner('Generating sound effect…', doGenerate, { done: null });
|
|
393
457
|
const filename = opts.output || 'sound.mp3';
|
|
394
|
-
const savedPath = await downloadFile(result.url, filename);
|
|
458
|
+
const savedPath = await downloadFile(result.url, filename, !!opts.output);
|
|
395
459
|
if (opts.json) {
|
|
396
460
|
console.log(JSON.stringify({ ...result, saved: savedPath }));
|
|
397
461
|
}
|
package/dist/commands/job.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { get, post, del, getBaseUrl, getAuthHeader } from '../api.js';
|
|
3
3
|
import { requireConfig } from '../config.js';
|
|
4
4
|
import { error as clrError, bold, muted, success, warning as warn } from '../colors.js';
|
|
5
|
-
import { run, printList } from '../helpers/index.js';
|
|
5
|
+
import { run, printList, resolveBody, parseDuration } from '../helpers/index.js';
|
|
6
6
|
export const jobCommand = new Command('job')
|
|
7
7
|
.description('Run long jobs (CPU/GPU)')
|
|
8
8
|
.addHelpText('after', '\nCPU sandbox or GPU (gpu-small=L4, gpu-medium=A10G, gpu-large=A100, gpu-huge=H100). Declared in the gipity.yaml jobs: phase; submit and poll via the subcommands below.');
|
|
@@ -23,14 +23,14 @@ jobCommand
|
|
|
23
23
|
}));
|
|
24
24
|
jobCommand
|
|
25
25
|
.command('submit <name> [body]')
|
|
26
|
-
.description('Submit a job (returns a run guid;
|
|
27
|
-
.option('--data <json>', 'JSON input body')
|
|
26
|
+
.description('Submit a job (returns a run guid; block on it with `job wait <guid>`)')
|
|
27
|
+
.option('-d, --data <json>', 'JSON input body: inline JSON, @file to read a file, or @- / - for stdin')
|
|
28
|
+
.option('--file <field=@path>', 'Attach a file as { data, media_type } under <field> (the vision/media service shape), repeatable, e.g. --file image=@scan.png', (v, acc) => (acc || []).concat(v))
|
|
28
29
|
.option('--idempotency-key <key>', 'Idempotency key (replays return the existing run)')
|
|
29
30
|
.option('--json', 'Output as JSON')
|
|
30
31
|
.action((name, bodyArg, opts) => run('Submit', async () => {
|
|
31
32
|
const config = requireConfig();
|
|
32
|
-
const
|
|
33
|
-
const input = JSON.parse(raw);
|
|
33
|
+
const input = resolveBody(bodyArg || opts.data, opts.file);
|
|
34
34
|
const body = { input };
|
|
35
35
|
if (opts.idempotencyKey)
|
|
36
36
|
body.idempotency_key = opts.idempotencyKey;
|
|
@@ -41,6 +41,12 @@ jobCommand
|
|
|
41
41
|
else {
|
|
42
42
|
const tag = res.data.replayed ? warn(' (replayed)') : '';
|
|
43
43
|
console.log(`${bold(res.data.run_guid)} ${muted(res.data.status)}${tag}`);
|
|
44
|
+
// Awaiting the run is the natural next step after every submit; point the
|
|
45
|
+
// agent at the blocking command so it doesn't hand-roll a status poll loop.
|
|
46
|
+
const TERMINAL = new Set(['success', 'failed', 'cancelled', 'canceled', 'error']);
|
|
47
|
+
if (!TERMINAL.has(res.data.status)) {
|
|
48
|
+
console.error(muted(`Block until it finishes: gipity job wait ${res.data.run_guid}`));
|
|
49
|
+
}
|
|
44
50
|
}
|
|
45
51
|
}));
|
|
46
52
|
jobCommand
|
|
@@ -66,6 +72,71 @@ jobCommand
|
|
|
66
72
|
if (r.output)
|
|
67
73
|
console.log(`output: ${JSON.stringify(r.output)}`);
|
|
68
74
|
}));
|
|
75
|
+
jobCommand
|
|
76
|
+
.command('wait <runGuid>')
|
|
77
|
+
.description('Block until a job run finishes, or exit at --timeout so the shell never hangs (poll again to keep waiting)')
|
|
78
|
+
.option('--timeout <seconds>', 'Max seconds to wait before giving up and reporting current progress. Bare number = seconds; an explicit unit (90s) means the same here and on `sandbox run --timeout`.', '90')
|
|
79
|
+
.option('--interval <seconds>', 'Seconds between polls', '3')
|
|
80
|
+
.option('--json', 'Output as JSON')
|
|
81
|
+
.action((runGuid, opts) => run('Wait', async () => {
|
|
82
|
+
const config = requireConfig();
|
|
83
|
+
// Terminal states end the wait; anything else means "still working, keep polling".
|
|
84
|
+
const TERMINAL = new Set(['success', 'failed', 'cancelled', 'canceled', 'error']);
|
|
85
|
+
const durOpt = parseDuration(opts.timeout, 's');
|
|
86
|
+
const timeoutSec = durOpt ? Math.max(1, Math.round(durOpt.value)) : parseInt(opts.timeout, 10);
|
|
87
|
+
const timeout = Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 90;
|
|
88
|
+
const interval = Math.max(1, parseInt(opts.interval, 10) || 3);
|
|
89
|
+
const deadline = Date.now() + timeout * 1000;
|
|
90
|
+
const fetchRun = async () => (await get(`/projects/${config.projectGuid}/jobs/runs/${encodeURIComponent(runGuid)}`)).data;
|
|
91
|
+
let r = await fetchRun();
|
|
92
|
+
let lastProgress = '';
|
|
93
|
+
while (!TERMINAL.has(r.status) && Date.now() < deadline) {
|
|
94
|
+
// Surface progress changes to stderr so an agent watching sees liveness
|
|
95
|
+
// without the poll noise polluting the machine-readable stdout result.
|
|
96
|
+
if (!opts.json) {
|
|
97
|
+
const prog = r.progress_pct != null
|
|
98
|
+
? `${Math.round(r.progress_pct * 100)}%${r.progress_message ? ` (${r.progress_message})` : ''}`
|
|
99
|
+
: r.status;
|
|
100
|
+
if (prog !== lastProgress) {
|
|
101
|
+
console.error(muted(`… ${prog}`));
|
|
102
|
+
lastProgress = prog;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const remaining = deadline - Date.now();
|
|
106
|
+
if (remaining <= 0)
|
|
107
|
+
break;
|
|
108
|
+
await new Promise(res => setTimeout(res, Math.min(interval * 1000, remaining)));
|
|
109
|
+
r = await fetchRun();
|
|
110
|
+
}
|
|
111
|
+
const done = TERMINAL.has(r.status);
|
|
112
|
+
if (opts.json) {
|
|
113
|
+
console.log(JSON.stringify(done ? r : { ...r, waiting: true, timed_out: true }));
|
|
114
|
+
}
|
|
115
|
+
else if (!done) {
|
|
116
|
+
// Not an error - the job is simply still going. Say so plainly and tell
|
|
117
|
+
// the agent exactly how to resume, then exit non-zero so a chained `&&`
|
|
118
|
+
// doesn't proceed as if the run had finished.
|
|
119
|
+
const prog = r.progress_pct != null ? ` ${Math.round(r.progress_pct * 100)}%` : '';
|
|
120
|
+
console.log(`${warn('still running')}${prog}${r.progress_message ? ` (${r.progress_message})` : ''} ${muted(runGuid)}`);
|
|
121
|
+
console.log(muted(`Not finished after ${timeout}s. Run \`gipity job wait ${runGuid}\` again to keep waiting.`));
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const statusColor = r.status === 'success' ? success : clrError;
|
|
125
|
+
console.log(`${statusColor(r.status)} ${muted(r.guid)}`);
|
|
126
|
+
if (r.duration_ms != null)
|
|
127
|
+
console.log(`duration: ${r.duration_ms}ms`);
|
|
128
|
+
if (r.error)
|
|
129
|
+
console.log(`${clrError('error:')} ${r.error}`);
|
|
130
|
+
if (r.output)
|
|
131
|
+
console.log(`output: ${JSON.stringify(r.output)}`);
|
|
132
|
+
}
|
|
133
|
+
// Exit codes: success → 0, failed/cancelled → 1, still-running-at-timeout → 2
|
|
134
|
+
// (distinct from a real failure so agents can tell "not yet" from "broke").
|
|
135
|
+
if (!done)
|
|
136
|
+
process.exit(2);
|
|
137
|
+
if (r.status !== 'success')
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}));
|
|
69
140
|
jobCommand
|
|
70
141
|
.command('runs <name>')
|
|
71
142
|
.description('List recent runs for a job')
|
|
@@ -87,6 +158,7 @@ jobCommand
|
|
|
87
158
|
.description('Stream live logs for a job run (--no-follow for a one-shot snapshot)')
|
|
88
159
|
.option('--follow', 'Stream via SSE (default)', true)
|
|
89
160
|
.option('--no-follow', 'One-shot snapshot of run state instead of streaming')
|
|
161
|
+
.option('--timeout <seconds>', 'Stop streaming after N seconds and exit cleanly, so a bounded peek at the live log never hangs the shell (no shell `timeout` wrapper needed). Bare number = seconds; unit (20s) also accepted.')
|
|
90
162
|
.option('--json', 'Output as JSON')
|
|
91
163
|
.action((runGuid, opts) => run('Logs', async () => {
|
|
92
164
|
const config = requireConfig();
|
|
@@ -95,6 +167,16 @@ jobCommand
|
|
|
95
167
|
console.log(opts.json ? JSON.stringify(res.data) : JSON.stringify(res.data, null, 2));
|
|
96
168
|
return;
|
|
97
169
|
}
|
|
170
|
+
// Optional bounded peek: close the stream after --timeout seconds instead of
|
|
171
|
+
// holding open until the run finishes. Lets an agent glance at early stdout
|
|
172
|
+
// (e.g. catch an import error during cold start) without wrapping the command
|
|
173
|
+
// in a shell `timeout`, then poll again to keep watching.
|
|
174
|
+
let peekSeconds = 0;
|
|
175
|
+
if (opts.timeout != null) {
|
|
176
|
+
const dur = parseDuration(String(opts.timeout), 's');
|
|
177
|
+
const secs = dur ? dur.value : parseFloat(String(opts.timeout));
|
|
178
|
+
peekSeconds = Number.isFinite(secs) && secs > 0 ? secs : 0;
|
|
179
|
+
}
|
|
98
180
|
// SSE stream. Manual fetch + line buffering - Node's EventSource isn't built-in,
|
|
99
181
|
// and the SSE format is simple enough to parse inline (event: <name>\ndata: <json>\n\n).
|
|
100
182
|
const url = `${getBaseUrl()}/projects/${config.projectGuid}/jobs/runs/${encodeURIComponent(runGuid)}/logs/stream`;
|
|
@@ -102,8 +184,28 @@ jobCommand
|
|
|
102
184
|
const headers = { 'Accept': 'text/event-stream' };
|
|
103
185
|
if (authHeader)
|
|
104
186
|
headers.Authorization = authHeader;
|
|
105
|
-
|
|
187
|
+
// Abort the underlying request when the peek window elapses so the socket is
|
|
188
|
+
// torn down cleanly rather than left dangling.
|
|
189
|
+
const controller = new AbortController();
|
|
190
|
+
let peekTimer;
|
|
191
|
+
let peekedOut = false;
|
|
192
|
+
if (peekSeconds > 0) {
|
|
193
|
+
peekTimer = setTimeout(() => { peekedOut = true; controller.abort(); }, peekSeconds * 1000);
|
|
194
|
+
}
|
|
195
|
+
let res;
|
|
196
|
+
try {
|
|
197
|
+
res = await fetch(url, { headers, signal: controller.signal });
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
if (peekedOut) {
|
|
201
|
+
console.error(muted(`… still streaming after ${peekSeconds}s. Run \`gipity job logs ${runGuid} --timeout ${Math.round(peekSeconds)}\` again to keep watching, or \`gipity job wait ${runGuid}\` to block until it finishes.`));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
throw e;
|
|
205
|
+
}
|
|
106
206
|
if (!res.ok || !res.body) {
|
|
207
|
+
if (peekTimer)
|
|
208
|
+
clearTimeout(peekTimer);
|
|
107
209
|
console.error(clrError(`Failed to open stream: HTTP ${res.status}`));
|
|
108
210
|
process.exit(1);
|
|
109
211
|
}
|
|
@@ -112,9 +214,24 @@ jobCommand
|
|
|
112
214
|
let buffer = '';
|
|
113
215
|
let currentEvent = '';
|
|
114
216
|
while (true) {
|
|
115
|
-
|
|
116
|
-
|
|
217
|
+
let chunk;
|
|
218
|
+
try {
|
|
219
|
+
chunk = await reader.read();
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
// Peek window elapsed mid-stream: report cleanly and exit 0 (not an error).
|
|
223
|
+
if (peekedOut) {
|
|
224
|
+
console.error(muted(`… still streaming after ${peekSeconds}s. Run \`gipity job logs ${runGuid} --timeout ${Math.round(peekSeconds)}\` again to keep watching, or \`gipity job wait ${runGuid}\` to block until it finishes.`));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
throw e;
|
|
228
|
+
}
|
|
229
|
+
const { done, value } = chunk;
|
|
230
|
+
if (done) {
|
|
231
|
+
if (peekTimer)
|
|
232
|
+
clearTimeout(peekTimer);
|
|
117
233
|
break;
|
|
234
|
+
}
|
|
118
235
|
buffer += decoder.decode(value, { stream: true });
|
|
119
236
|
let nl;
|
|
120
237
|
while ((nl = buffer.indexOf('\n')) >= 0) {
|