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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,9 @@
concurrency policy, and system-only consumer Functions in local bundles.
- Preserve queue-free schema-2 archive compatibility while including declared
queues in deterministic artifacts and development capability validation.
- Validate declarative brokered integration slots locally, including
project-contained Asana access and app-owned HubSpot CRM capabilities, while
keeping integration-free archives byte-compatible with older releases.
- Add `jobs list|get` for retained production depth, created/retried/succeeded/
failed rollups, inclusive creation-time filtering, cursor pagination, and
metadata-only job inspection without payloads or idempotency keys.
Expand Down
35 changes: 35 additions & 0 deletions src/bundle.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,6 +158,9 @@ functions:
expect(await readArchivedManifest(root, first.archive)).not.toHaveProperty(
"queues",
);
expect(await readArchivedManifest(root, first.archive)).not.toHaveProperty(
"integrations",
);
});

it("validates and archives declared background queues", async () => {
Expand DownExpand Up@@ -200,6 +203,38 @@ queues:
expect(archived.queues).toEqual(bundle.manifest.queues);
});

it("validates and archives declared HubSpot CRM integrations", async () => {
const root = await temporaryDirectory();
await mkdir(path.join(root, "frontend"));
await writeFile(path.join(root, "frontend", "index.html"), "hello");
await writeManifest(
root,
`
frontend:
directory: frontend
integrations:
crm:
provider: hubspot-crm
account: app
cardinality: one
capabilities:
- crm.contacts.read
- crm.contacts.write
`,
);

const bundle = await buildBundle(root);
const archived = await readArchivedManifest(root, bundle.archive);

expect(bundle.manifest.integrations.crm).toEqual({
provider: "hubspot-crm",
account: "app",
cardinality: "one",
capabilities: ["crm.contacts.read", "crm.contacts.write"],
});
expect(archived.integrations).toEqual(bundle.manifest.integrations);
});

it("never archives local .opencloud development metadata", async () => {
const root = await temporaryDirectory();
await mkdir(path.join(root, ".opencloud"));
Expand Down
102 changes: 57 additions & 45 deletions src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,11 @@ import {
openBrowser,
revokeAccountCredential,
} from "./account-auth.js";
import { buildBundle, OPEN_CLOUD_E2E_TEST_PATH } from "./bundle.js";
import {
buildBundle,
OPEN_CLOUD_E2E_TEST_PATH,
serializeBundleManifest,
} from "./bundle.js";
import { CredentialStore } from "./credential-store.js";
import { doctorDiagnostics } from "./doctor.js";
import { devDataRequest, type DevDataAction } from "./dev-data.js";
Expand DownExpand Up@@ -115,7 +119,9 @@ function client(): OpenCloudClient {
const apiUrl = options.apiUrl ?? binding?.apiUrl ?? legacy?.apiUrl;
if (options.token) {
if (!apiUrl) {
throw new Error("Pass --api-url with --token outside a connected workspace.");
throw new Error(
"Pass --api-url with --token outside a connected workspace.",
);
}
return new OpenCloudClient({ apiUrl, token: options.token });
}
Expand DownExpand Up@@ -338,7 +344,7 @@ async function synchronizeValidatedDraft(
for (const file of bundle.files) {
const content =
file === "opencloud.json"
? Buffer.from(`${JSON.stringify(bundle.manifest, null, 2)}\n`)
? Buffer.from(serializeBundleManifest(bundle.manifest))
: await readFile(path.join(sourceRoot, ...file.split("/")));
local.set(file, {
content,
Expand DownExpand Up@@ -547,9 +553,10 @@ program
process.stderr.write(
`Open this URL to approve the CLI:\n${authorization.verificationUriComplete}\n`,
);
const browserOpened = options.browser !== false
? openBrowser(authorization.verificationUriComplete)
: false;
const browserOpened =
options.browser !== false
? openBrowser(authorization.verificationUriComplete)
: false;
const account = await completeDeviceAuthorization(authorization);
const stored = await credentialStore.saveAccount(account);
output({
Expand DownExpand Up@@ -652,16 +659,22 @@ async function logout(): Promise<void> {
currentWorkspaceCredentialRemoved: Boolean(binding),
workspaceBindingRetained: binding ? workspaceFile() : null,
legacyOnboardingSessionRemoved: legacyOnboardingSessionRemoved
? legacyOnboardingSession?.state ?? true
? (legacyOnboardingSession?.state ?? true)
: false,
next: binding
? "The non-secret app binding remains. Run opencloud login to reconnect it later."
: "Run opencloud login to sign in again.",
});
}

auth.command("logout").description("Revoke and clear the CLI login").action(logout);
program.command("logout").description("Revoke and clear the CLI login").action(logout);
auth
.command("logout")
.description("Revoke and clear the CLI login")
.action(logout);
program
.command("logout")
.description("Revoke and clear the CLI login")
.action(logout);

program
.command("onboard")
Expand DownExpand Up@@ -772,7 +785,9 @@ program

program
.command("doctor")
.description("Print redacted CLI, identity, endpoint, and platform diagnostics")
.description(
"Print redacted CLI, identity, endpoint, and platform diagnostics",
)
.action(async () => {
const options = program.opts<{ apiUrl?: string; token?: string }>();
const file = sessionFile();
Expand DownExpand Up@@ -813,7 +828,7 @@ program
: stored
? "session-file"
: "none",
sessionState: binding ? "connected" : stored?.state ?? null,
sessionState: binding ? "connected" : (stored?.state ?? null),
appId:
binding?.appId ?? (stored?.state === "ready" ? stored.appId : null),
credentialExpiresAt:
Expand DownExpand Up@@ -845,7 +860,9 @@ app
.option("--idempotency-key <key>")
.action(async (options) => {
output(
await (await managementClient()).call(
await (
await managementClient()
).call(
"createApp",
{
body: {
Expand All@@ -866,17 +883,13 @@ app
app
.command("list")
.description("List apps available to the signed-in account")
.action(async () =>
output(await (await managementClient()).get("/v1/apps")),
);
.action(async () => output(await (await managementClient()).get("/v1/apps")));

app
.command("get")
.argument("<app-id>")
.action(async (appId) =>
output(
await (await managementClient()).get(`/v1/apps/${appId}`),
),
output(await (await managementClient()).get(`/v1/apps/${appId}`)),
);

app
Expand DownExpand Up@@ -924,11 +937,7 @@ app
version?: string;
sdkVersion?: string;
};
if (
!deployment.id ||
!deployment.version ||
!deployment.sdkVersion
) {
if (!deployment.id || !deployment.version || !deployment.sdkVersion) {
throw new Error(
"The active deployment does not expose an OpenCloud SDK pin",
);
Expand DownExpand Up@@ -980,16 +989,18 @@ email
.option("--limit <number>", "maximum records", "100")
.option("--alias <alias>", "filter by a manifest-declared alias")
.addOption(
new Option("--direction <direction>", "filter by message direction").choices([
"inbound",
"outbound",
]),
new Option(
"--direction <direction>",
"filter by message direction",
).choices(["inbound", "outbound"]),
)
.option("--from <iso>", "messages created at or after this ISO timestamp")
.option("--to <iso>", "messages created at or before this ISO timestamp")
.action(async (appId, options) => {
output(
await (await managementClient()).call("getAppEmail", {
await (
await managementClient()
).call("getAppEmail", {
appId: String(appId),
query: emailHistoryQuery(options),
}),
Expand All@@ -1003,7 +1014,9 @@ email
.argument("<message-id>")
.action(async (appId, messageId) => {
output(
await (await managementClient()).call("getAppEmailMessage", {
await (
await managementClient()
).call("getAppEmailMessage", {
appId: String(appId),
messageId: String(messageId),
}),
Expand DownExpand Up@@ -1141,8 +1154,7 @@ dev
const state = await requireDevState(callerPath(directory));
const body = devDataRequest(String(table), action as DevDataAction, {
id: options.id === undefined ? undefined : String(options.id),
values:
options.values === undefined ? undefined : String(options.values),
values: options.values === undefined ? undefined : String(options.values),
});
output(
await client().call("mutateDevData", {
Expand DownExpand Up@@ -1203,15 +1215,17 @@ devEmail
new Option("--text <text>", "plain-text body").conflicts("textFile"),
)
.addOption(
new Option("--text-file <path>", "read the plain-text body from a file").conflicts(
"text",
),
new Option(
"--text-file <path>",
"read the plain-text body from a file",
).conflicts("text"),
)
.addOption(new Option("--html <html>", "HTML body").conflicts("htmlFile"))
.addOption(
new Option("--html-file <path>", "read the HTML body from a file").conflicts(
"html",
),
new Option(
"--html-file <path>",
"read the HTML body from a file",
).conflicts("html"),
)
.option("--reply-to <address>", "reserved .test reply-to address")
.option(
Expand DownExpand Up@@ -1300,7 +1314,9 @@ dev

dev
.command("receipts")
.description("List exact-revision verification evidence, even after dev stops")
.description(
"List exact-revision verification evidence, even after dev stops",
)
.argument("[directory]", "app source directory", ".")
.option("--limit <number>", "maximum records", "50")
.action(async (directory, options) => {
Expand DownExpand Up@@ -1754,7 +1770,7 @@ program
for (const file of bundle.files) {
const content =
file === "opencloud.json"
? Buffer.from(`${JSON.stringify(bundle.manifest, null, 2)}\n`)
? Buffer.from(serializeBundleManifest(bundle.manifest))
: await readFile(path.join(sourceRoot, ...file.split("/")));
changes.push({
path: file,
Expand DownExpand Up@@ -1997,9 +2013,7 @@ jobs
.action(async (appId, options) => {
const query = backgroundJobsQuery(options);
output(
await client().get(
`/v1/apps/${encodeURIComponent(appId)}/jobs?${query}`,
),
await client().get(`/v1/apps/${encodeURIComponent(appId)}/jobs?${query}`),
);
});

Expand All@@ -2017,9 +2031,7 @@ const secret = program

secret
.command("rotate")
.description(
"Rotate a manifest-generated secret without returning its value",
)
.description("Rotate a manifest-generated secret without returning its value")
.argument("<app-id>")
.argument("<name>")
.option("--bytes <number>", "random byte count", "32")
Expand Down
24 changes: 16 additions & 8 deletions vendor/bundler/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,7 @@ interface AuthorManifest {
email?: unknown;
health?: unknown;
secrets?: Record<string, unknown>;
integrations?: Record<string, unknown>;
}

export interface BuiltBundle {
Expand DownExpand Up@@ -79,6 +80,19 @@ export interface BundleOptions {
version?: string;
}

export function serializeBundleManifest(manifest: OpenCloudManifest): string {
const archiveManifest: Partial<OpenCloudManifest> = { ...manifest };
// Queue-free schema-2 apps keep the archive shape accepted by older
// platform releases while declared queues remain canonical bundle input.
if (manifest.queues.length === 0) delete archiveManifest.queues;
// Integration-free apps likewise retain the archive shape accepted by
// platform releases that predate declarative provider bindings.
if (Object.keys(manifest.integrations).length === 0) {
delete archiveManifest.integrations;
}
return `${JSON.stringify(archiveManifest, null, 2)}\n`;
}

interface BundleSelection {
files: Map<string, string>;
directories: Set<string>;
Expand DownExpand Up@@ -183,13 +197,9 @@ export async function buildBundle(
await copyFile(sourceFile, destination);
await chmod(destination, 0o644);
}
const archiveManifest: Partial<OpenCloudManifest> = { ...manifest };
// Queue-free schema-2 apps keep the archive shape accepted by older
// platform releases while declared queues remain canonical bundle input.
if (manifest.queues.length === 0) delete archiveManifest.queues;
await writeFile(
path.join(staging, "opencloud.json"),
`${JSON.stringify(archiveManifest, null, 2)}\n`,
serializeBundleManifest(manifest),
{ flag: "wx", mode: 0o644 },
);

Expand DownExpand Up@@ -227,9 +237,7 @@ export async function buildBundle(
}
}

export function assertE2eTestOutsideFrontend(
frontendDirectory: string,
): void {
export function assertE2eTestOutsideFrontend(frontendDirectory: string): void {
const relative = path.posix.relative(
frontendDirectory,
OPEN_CLOUD_E2E_TEST_PATH,
Expand Down
Loading
Loading