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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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 number Diff line number Diff 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 Down Expand 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 Down Expand 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 Down Expand 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