diff --git a/README.md b/README.md index a7f46c5c..af6e5170 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,22 @@ Choose by what the page is: - Slide-deck-like experiences: local unbilled `compose render`. - Live video, iframe, or webapp: write playlist primitives. +## Application command results + +`app upload` and `app update` return the same JSON data paths with or without +`--no-wait`: `data.application` contains the accepted application `id`, +`release_id`, and `operation_id`; `data.pack` contains `sha256` and `file_count`. +The accepted response does not include an application revision. Read `app show` +for the current revision before an update. + +`data.operation` contains the observed completed operation when waiting. With +`--no-wait` it is `null`: upload acceptance does not establish operation state +or release readiness. Use `operations get ` or +`operations wait ` to observe processing. The envelope's +`operation_id` identifies that same operation in either mode. Existing flat +accepted fields (`data.id`, `data.release_id`, `data.operation_id`) and +`data.sha256` remain available as compatibility aliases in both modes. + ## Configuration User-private config lives outside the replaceable plugin directory diff --git a/src/app-output.test.ts b/src/app-output.test.ts new file mode 100644 index 00000000..a9db4e73 --- /dev/null +++ b/src/app-output.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, open, rename, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { Writable } from "node:stream"; +import { test } from "node:test"; +import { writeConfigAtomic } from "./config.js"; +import { run } from "./main.js"; +import { testTemp } from "./test-temp.js"; +import { memoryBackend } from "./transport/fake.js"; + +for (const action of ["upload", "update"] as const) { + test(`app ${action} keeps canonical and compatibility output paths across waiting modes`, async (t) => { + const configDir = await testTemp("app-output-"); + t.after(() => rm(configDir, { recursive: true, force: true })); + const fs = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } }; + await writeConfigAtomic(path.join(configDir, "screenrig", "config.json"), { + api_url: "https://api.screenrig.ai", token: "sr_live_existing_secret", + }, fs); + const appDir = path.join(configDir, "app"); + await mkdir(appDir); + await writeFile(path.join(appDir, "index.html"), "hello"); + const results = []; + for (const noWait of [false, true]) { + const transport = memoryBackend(); + if (action === "update") { + transport.on("POST", "/api/v1/applications/app_EXISTING/releases", (request) => { + assert.equal(request.headers?.["if-match"], '"7"'); + return { status: 202, headers: {}, body: { + id: "app_EXISTING", release_id: "rel_NEW", operation_id: "op_NEW", + } }; + }); + } + let output = ""; + const stdout = new Writable({ write(chunk, _encoding, callback) { output += chunk; callback(); } }); + const stderr = new Writable({ write(_chunk, _encoding, callback) { callback(); } }); + const argv = ["--json", "app", action, + ...(action === "update" ? ["app_EXISTING", appDir, "--if-match", "7"] : [appDir]), + ...(noWait ? ["--no-wait"] : ["--poll-ms", "1"]), + ]; + const code = await run({ + argv, env: fs.env, stdout, stderr, fs, homedir: fs.homedir, + cwd: () => configDir, transport, + now: () => new Date("2026-09-10T00:00:00Z"), sleep: async () => undefined, + }); + stdout.end(); + stderr.end(); + assert.equal(code, 0, output); + const envelope = JSON.parse(output); + const data = envelope.data; + const expectedId = action === "update" ? "app_EXISTING" : "app_AAAAAAAAAAAAAAAAAAAAAAAA"; + const expectedRelease = action === "update" ? "rel_NEW" : "rel_AAAAAAAAAAAAAAAAAAAAAAAA"; + assert.equal(data.application.id, expectedId); + assert.equal(data.application.release_id, expectedRelease); + assert.equal(data.id, data.application.id); + assert.equal(data.release_id, data.application.release_id); + assert.equal(data.operation_id, data.application.operation_id); + assert.equal(envelope.operation_id, data.application.operation_id); + assert.equal(data.sha256, data.pack.sha256); + assert.match(data.pack.sha256, /^[a-f0-9]{64}$/); + assert.ok(data.pack.file_count > 0); + const polls = transport.calls.filter((call) => call.path.startsWith("/api/v1/operations/")); + if (noWait) { + assert.equal(data.operation, null); + assert.equal(polls.length, 0, "no-wait must not poll to manufacture operation state"); + } else { + assert.equal(data.operation.id, envelope.operation_id); + assert.equal(data.operation.state, "succeeded"); + assert.ok(polls.length > 0); + } + results.push(data); + } + assert.deepEqual(results[0].application, results[1].application); + assert.deepEqual(results[0].pack, results[1].pack); + assert.deepEqual(Object.keys(results[0]).sort(), Object.keys(results[1]).sort()); + }); +} diff --git a/src/commands.ts b/src/commands.ts index 0354ef12..9f6e1107 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1759,39 +1759,36 @@ async function appUpload(args: ParsedArgs, runtime: CliRuntime, resolved: Awaite body: packed.archive, }); const body = response.body as OperationAccepted; - if (!flagBool(args.flags, "no-wait") && body.operation_id) { - const operation = await client.waitForOperation(body.operation_id, { + const operation = !flagBool(args.flags, "no-wait") && body.operation_id + ? await client.waitForOperation(body.operation_id, { timeoutMs: flagNumber(args.flags, "timeout") ?? 120_000, pollMs: flagNumber(args.flags, "poll-ms") ?? 1000, sleep: runtime.sleep, - }); - return { - envelope: successEnvelope( - { application: body, operation, pack: { sha256: packed.sha256, file_count: packed.file_count } }, - { request_id: client.requestId, operation_id: operation.id }, - ), - exitCode: ExitCode.Success, - human: humanLines(update ? "Application release uploaded" : "Application uploaded", [ + }) + : null; + return { + envelope: jsonBody(response, client.requestId, { + // Keep the historical flat accepted fields and sha256 as aliases while + // exposing the same canonical paths regardless of waiting mode. + application: body, + operation, + pack: { sha256: packed.sha256, file_count: packed.file_count }, + sha256: packed.sha256, + }), + exitCode: ExitCode.Success, + human: humanLines( + operation + ? (update ? "Application release uploaded" : "Application uploaded") + : (update ? "Application release accepted" : "Application upload accepted"), + [ ["application_id", body.id], - // The release id is the only handle an application primitive accepts, so - // report it here rather than making the caller read the operation - // result to find it. + // Application primitives use the release id, not the application id. ["release_id", body.release_id], - ["operation_id", operation.id], - ["state", operation.state], + ["operation_id", body.operation_id], + ["state", operation?.state], ["sha256", packed.sha256], - ]), - }; - } - return { - envelope: jsonBody(response, client.requestId, { sha256: packed.sha256 }), - exitCode: ExitCode.Success, - human: humanLines(update ? "Application release accepted" : "Application upload accepted", [ - ["application_id", body.id], - ["release_id", body.release_id], - ["operation_id", body.operation_id], - ["sha256", packed.sha256], - ]), + ], + ), }; }