Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <operation_id>` or
`operations wait <operation_id>` 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
Expand Down
76 changes: 76 additions & 0 deletions src/app-output.test.ts
Original file line number Diff line number Diff line change
@@ -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"), "<!doctype html><html><head></head><body>hello</body></html>");
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());
});
}
51 changes: 24 additions & 27 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
]),
],
),
};
}

Expand Down
Loading