@ingram-cloud/pulumi 1.0.0

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/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # @ingram-cloud/pulumi
2
+
3
+ Pulumi dynamic resources for the [Ingram Cloud](https://cloud.ingram.tech) `/v1`
4
+ API. Declare your tenant's IC configuration — agents, MCP servers, integrations,
5
+ webhooks, BYOK model keys — as Pulumi resources instead of imperative
6
+ `register-*.ts` / `ensure-agent.ts` scripts, so it lives in state and a change
7
+ is a `pulumi up`.
8
+
9
+ The library is published alongside the Ingram Cloud `/v1` API so the wrapper
10
+ tracks the surface it wraps. The API version it targets is pinned per library
11
+ version (`IC_API_VERSION`).
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ bun add @ingram-cloud/pulumi @pulumi/pulumi
17
+ ```
18
+
19
+ ## How the repos interact
20
+
21
+ ```
22
+ cloud.ingram.tech/pulumi/ THIS LIBRARY — knows nothing about any consumer
23
+ (Agent, McpServer, TelegramBot, WhatsAppConfig,
24
+ Webhook, ModelKey, Secret)
25
+
26
+ app repos (thornhill, …) declare their AGENTS — the `instructions` are
27
+ app-owned content (prompts), so they stay in the app
28
+ repo, sourced from the app's own spec module. The app's
29
+ Pulumi program imports this library + those specs.
30
+
31
+ infra repo (~/src/infra) declares tenant INTEGRATION config (MCP, Telegram, WhatsApp,
32
+ webhook, model keys) alongside DNS/DB/Vercel, and can
33
+ read an app stack's outputs via pulumi.StackReference to
34
+ push IC-derived values (agent ids, webhook secret)
35
+ into Vercel env. No prompts ever live here.
36
+ ```
37
+
38
+ The hard rule: **prompts never leave the app repo.** This library carries no
39
+ content; it only carries the machinery.
40
+
41
+ ## Connection
42
+
43
+ Every resource takes `baseUrl` + `token` (the tenant-admin bearer; `IcProject` /
44
+ `IcProjectToken` take an **organization** key instead — see below). Resolve them
45
+ once with `connectionFromConfig()` and spread:
46
+
47
+ ```ts
48
+ import * as ic from "@ingram-cloud/pulumi";
49
+
50
+ const conn = ic.connectionFromConfig(); // ingram-cloud:token (secret) / :baseUrl
51
+ // or env INGRAM_CLOUD_TOKEN|CLOUD_API_KEY
52
+ ```
53
+
54
+ Set the token as a stack secret:
55
+
56
+ ```bash
57
+ pulumi config set --secret ingram-cloud:token tha_live_…
58
+ ```
59
+
60
+ ## Resources
61
+
62
+ ### IcAgent (declare in the APP repo)
63
+
64
+ Create-or-adopt by **`slug`** → publish a new immutable version **only when the
65
+ content changed** → roll it out. Mirrors the old `ensure-agent.ts` idempotency, so
66
+ the first `pulumi up` after switching from a script **adopts** the existing agent
67
+ (matched by slug) rather than recreating it.
68
+
69
+ `slug` is the immutable reconcile key; it defaults to the Pulumi resource name
70
+ (here `"curator"`), so existing stacks keep adopting the same agent. `name` is a
71
+ free display label you can rename without churning the agent. Changing `slug`
72
+ replaces it.
73
+
74
+ ```ts
75
+ import { AGENT_SPECS } from "../src/lib/cloud/agent-spec"; // app-owned
76
+
77
+ const curator = new ic.IcAgent("curator", {
78
+ ...conn,
79
+ // slug defaults to "curator"; pass `slug` to decouple the key from the name.
80
+ name: AGENT_SPECS.curator.name, // display label, freely renamable
81
+ instructions: AGENT_SPECS.curator.instructions,
82
+ model: AGENT_SPECS.curator.model,
83
+ autoMemory: AGENT_SPECS.curator.auto_memory,
84
+ // memoryConsolidation: true, // opt into background consolidation (billed)
85
+ // variables: [...], enabledHostedTools: [...], rolloutPercent: 100,
86
+ });
87
+
88
+ export const curatorAgentId = curator.agentId;
89
+ ```
90
+
91
+ > Attaching **existing** smiths to a agent is a one-time fleet backfill —
92
+ > keep it as the app's migration script. New smiths attach at birth in app code.
93
+ > Pulumi does not own per-smith runtime state.
94
+
95
+ ### IcMcpServer, IcOauthProvider, IcTelegramBot, IcWhatsAppConfig, IcEmail, IcWebhook, IcModelKey (declare in INFRA)
96
+
97
+ ```ts
98
+ // Your own server (raw URL).
99
+ new ic.IcMcpServer("thornhill-mcp", {
100
+ ...conn, serverName: "thornhill", url: `${APP_URL}/api/mcp`,
101
+ authKind: "static", secret: mcpSecret,
102
+ });
103
+
104
+ // An `oauth`-kind server forwards each end-user's own stored token; its
105
+ // provider record tells IC how tokens refresh. Webhook-delegation mode (a
106
+ // refreshWebhook and NO clientSecret): IC POSTs { connection_id } to the
107
+ // tenant near expiry and the tenant PATCHes a fresh pair back — the tenant
108
+ // owns the token format entirely.
109
+ new ic.IcMcpServer("thornhill-user-mcp", {
110
+ ...conn, serverName: "thornhill-user", url: `${APP_URL}/api/user-mcp`,
111
+ authKind: "oauth", authProvider: "thornhill",
112
+ });
113
+ new ic.IcOauthProvider("thornhill-provider", {
114
+ ...conn, provider: "thornhill",
115
+ refreshWebhook: `${APP_URL}/api/ic/refresh`,
116
+ });
117
+
118
+ // A curated third party: stamp from the catalog, gate which tools run, and
119
+ // require human approval on the scary ones. Per-smith OAuth tokens are NOT
120
+ // declared here — the runtime collects each end-user's identity (the hosted
121
+ // connect flow vaults it). `clientMode: "platform"` uses Ingram's OAuth client.
122
+ new ic.IcMcpServer("stripe", {
123
+ ...conn, serverName: "stripe", catalog: "stripe",
124
+ toolAllowlist: ["get_balance", "list_charges", "create_refund"],
125
+ approvalPolicy: [{ match: "create_refund" }],
126
+ });
127
+
128
+ new ic.IcTelegramBot("thornhill-telegram", { ...conn, botToken });
129
+
130
+ // The agent's own email channel (BYO-Cloudflare transport). IC returns the
131
+ // inbound HMAC once — it becomes a secret output you flow into the inbound worker.
132
+ const email = new ic.IcEmail("thornhill-email", {
133
+ ...conn, cloudflareAccountId, cloudflareApiToken, fromDomain: "mail.thornhill.app",
134
+ });
135
+ export const inboundEmailSecret = email.inboundSecret; // create-time secret output
136
+
137
+ const hook = new ic.IcWebhook("thornhill-events", {
138
+ ...conn, url: `${APP_URL}/api/ic/events`,
139
+ events: ["run.completed", "approval.required", /* … */],
140
+ });
141
+ export const webhookSigningSecret = hook.secret; // whsec_… (create-only secret output)
142
+
143
+ new ic.IcModelKey("anthropic", { ...conn, provider: "anthropic", apiKey });
144
+ ```
145
+
146
+ `IcWebhook.secret` is the `whsec_…` signing secret IC returns exactly once — it
147
+ becomes a secret Pulumi output, so the infra stack can flow it straight into Vercel
148
+ env with no copy-paste.
149
+
150
+ ### IcProject + IcProjectToken (declare in the PLATFORM stack)
151
+
152
+ The tier **above** a project. These take an **organization key**
153
+ (`organization:*`, your account master key) — not a project token. Hand it to a
154
+ platform stack and it provisions projects and mints each one a project-scoped
155
+ `tenant:*` token, which you drop straight into that app's env. The org key reads
156
+ no run, memory, or smith itself; only the project tokens it mints can.
157
+
158
+ ```ts
159
+ // In the PLATFORM stack, `ingram-cloud:token` is your ORGANIZATION key.
160
+ const project = new ic.IcProject("thornhill", { ...conn }); // adopt-by-name
161
+ const icToken = new ic.IcProjectToken("thornhill-admin", {
162
+ ...conn,
163
+ project: project.projectId, // the project id == the tenant
164
+ });
165
+
166
+ // Flow the minted tenant token straight into the app's runtime env:
167
+ new vercel.ProjectEnvironmentVariable("th-ic-token", {
168
+ projectId: thornhillVercelId,
169
+ key: "INGRAM_CLOUD_TOKEN",
170
+ value: icToken.projectToken, // secret tenant:* token
171
+ targets: ["production"],
172
+ });
173
+ export const thornhillProjectId = project.projectId;
174
+ ```
175
+
176
+ `IcProject` adopts an existing project by `projectName` (defaults to the resource
177
+ name), so re-runs reconcile. `IcProjectToken.projectToken` is a secret output; it
178
+ re-mints on any change and revokes on destroy.
179
+
180
+ ## Importing resources a script already created
181
+
182
+ Agents and webhooks implement `read`, so you can adopt pre-existing ones:
183
+
184
+ ```bash
185
+ pulumi import 'ingram-cloud:index:IcAgent' curator agt_123…
186
+ ```
187
+
188
+ (Agents also self-adopt by slug on first `create`, so an explicit import is
189
+ usually unnecessary — a plain `pulumi up` will reconcile the live agent.)
190
+
191
+ ## Notes
192
+
193
+ - Built as CommonJS to match the Pulumi Node.js runtime and the infra stack's
194
+ `module: commonjs` tsconfig.
195
+ - `token` and every credential input are marked `additionalSecretOutputs`, so they
196
+ are encrypted in Pulumi state.
197
+ - CRUD runs inline in the Pulumi process over the global `fetch` (Node ≥18).
198
+ - `read` treats a server-side **404 as "gone"** (returns an empty id) so
199
+ `pulumi refresh` prunes a resource deleted out-of-band — e.g. after an IC DB
200
+ reset, `pulumi refresh && pulumi up` recreates it, no `pulumi state delete`.
201
+ Non-404 errors still throw, so a transient API blip can't drop it from state.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Connection helper — resolves the IC `{ baseUrl, token }` pair once so a Pulumi
3
+ * program can spread it into every resource:
4
+ *
5
+ * const conn = connectionFromConfig();
6
+ * new ic.IcAgent("assistant", { ...conn, name: "…", instructions: "…" });
7
+ *
8
+ * Resolution order (first hit wins):
9
+ * baseUrl: `ingram-cloud:baseUrl` config → IC_BASE_URL → CLOUD_BASE_URL → prod
10
+ * token: `ingram-cloud:token` secret config → INGRAM_CLOUD_TOKEN → CLOUD_API_KEY
11
+ *
12
+ * The env fallbacks bridge the two naming conventions already in the fleet
13
+ * (thornhill's INGRAM_CLOUD_TOKEN, integrain's CLOUD_API_KEY). The returned
14
+ * token is always a secret Output so it stays encrypted in state.
15
+ */
16
+ import * as pulumi from "@pulumi/pulumi";
17
+ export declare const DEFAULT_BASE_URL = "https://api.cloud.ingram.tech";
18
+ export interface IcConnection {
19
+ baseUrl: pulumi.Input<string>;
20
+ token: pulumi.Input<string>;
21
+ }
22
+ export declare function connectionFromConfig(cfg?: pulumi.Config): IcConnection;
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_BASE_URL = void 0;
37
+ exports.connectionFromConfig = connectionFromConfig;
38
+ /**
39
+ * Connection helper — resolves the IC `{ baseUrl, token }` pair once so a Pulumi
40
+ * program can spread it into every resource:
41
+ *
42
+ * const conn = connectionFromConfig();
43
+ * new ic.IcAgent("assistant", { ...conn, name: "…", instructions: "…" });
44
+ *
45
+ * Resolution order (first hit wins):
46
+ * baseUrl: `ingram-cloud:baseUrl` config → IC_BASE_URL → CLOUD_BASE_URL → prod
47
+ * token: `ingram-cloud:token` secret config → INGRAM_CLOUD_TOKEN → CLOUD_API_KEY
48
+ *
49
+ * The env fallbacks bridge the two naming conventions already in the fleet
50
+ * (thornhill's INGRAM_CLOUD_TOKEN, integrain's CLOUD_API_KEY). The returned
51
+ * token is always a secret Output so it stays encrypted in state.
52
+ */
53
+ const pulumi = __importStar(require("@pulumi/pulumi"));
54
+ exports.DEFAULT_BASE_URL = "https://api.cloud.ingram.tech";
55
+ function connectionFromConfig(cfg) {
56
+ const c = cfg ?? new pulumi.Config("ingram-cloud");
57
+ const baseUrl = c.get("baseUrl") ??
58
+ process.env.IC_BASE_URL ??
59
+ process.env.CLOUD_BASE_URL ??
60
+ exports.DEFAULT_BASE_URL;
61
+ const tokenFromCfg = c.getSecret("token");
62
+ const token = tokenFromCfg ??
63
+ pulumi.secret(process.env.INGRAM_CLOUD_TOKEN ?? process.env.CLOUD_API_KEY ?? "");
64
+ return { baseUrl, token };
65
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @ingram-cloud/pulumi
3
+ *
4
+ * Pulumi dynamic resources for the Ingram Cloud /v1 API. See README.md for the
5
+ * repo-interaction model (library here, agents declared in app repos,
6
+ * integration/tenant config in the infra repo).
7
+ */
8
+ export * from "./resources";
9
+ export * from "./connection";
package/dist/index.js ADDED
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ /**
18
+ * @ingram-cloud/pulumi
19
+ *
20
+ * Pulumi dynamic resources for the Ingram Cloud /v1 API. See README.md for the
21
+ * repo-interaction model (library here, agents declared in app repos,
22
+ * integration/tenant config in the infra repo).
23
+ */
24
+ __exportStar(require("./resources"), exports);
25
+ __exportStar(require("./connection"), exports);
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Ingram Cloud /v1 tenant configuration as Pulumi dynamic resources.
3
+ *
4
+ * Replaces the per-app `scripts/register-*.ts` / `ensure-agent.ts` one-shots
5
+ * with declarative resources, so IC wiring is a `pulumi up` and lives in state
6
+ * instead of drifting in someone's shell history.
7
+ *
8
+ * Each resource talks to the IC REST API. `token` is the tenant-admin bearer;
9
+ * keep it (and any credential input) a stack secret — it's marked as an
10
+ * additional secret output so it's encrypted in state.
11
+ *
12
+ * Where each resource is meant to be declared:
13
+ * - **Agents** (`IcAgent`) — the `instructions` are app-owned *content*,
14
+ * so declare these in the APP repo's Pulumi program, sourced from the app's
15
+ * own spec module. Never copy prompts into the infra repo.
16
+ * - **Integrations / tenant config** (`IcMcpServer`, `IcOauthProvider`,
17
+ * `IcTelegramBot`, `IcWhatsAppConfig`, `IcWebhook`, `IcModelKey`) —
18
+ * tenant-level wiring; declare in the infra repo alongside DNS/DB/Vercel.
19
+ *
20
+ * Not modelled here, on purpose:
21
+ * - **Per-end-user actions** — creating users, binding deployments, pushing
22
+ * per-user connection tokens, minting user tokens — happen at app runtime.
23
+ * - **Attaching existing smiths to an agent** — a one-time backfill that
24
+ * mutates the live fleet; keep it as the app's migration script. New
25
+ * smiths attach at birth in app code.
26
+ * - **Slack app factory** — config-token rotation is stateful and app-driven.
27
+ */
28
+ import * as pulumi from "@pulumi/pulumi";
29
+ /**
30
+ * The /v1 API version this library targets. Bumping the IC API contract is a
31
+ * library version bump — consumers pin behaviour by pinning this package.
32
+ */
33
+ export declare const IC_API_VERSION = "2026-05-01";
34
+ export interface AgentVariable {
35
+ name: string;
36
+ default?: string | null;
37
+ description?: string | null;
38
+ required?: boolean;
39
+ }
40
+ export interface IcAgentArgs {
41
+ baseUrl: pulumi.Input<string>;
42
+ token: pulumi.Input<string>;
43
+ /**
44
+ * Immutable, tenant-unique reconcile key (`[a-z0-9-]`). This is what the
45
+ * resource adopts on across runs. Defaults to the Pulumi resource name
46
+ * (slugified); set it explicitly to decouple the key from the resource name.
47
+ * Changing it replaces the agent.
48
+ */
49
+ slug?: pulumi.Input<string>;
50
+ /** Free display label (mutable, not unique). Defaults to the slug. */
51
+ name?: pulumi.Input<string>;
52
+ /** The persona/instruction template (may contain `{{ variables }}`). */
53
+ instructions?: pulumi.Input<string>;
54
+ model?: pulumi.Input<string>;
55
+ enabledHostedTools?: pulumi.Input<string[]>;
56
+ autoMemory?: pulumi.Input<boolean>;
57
+ /** Opt into observational-memory consolidation (background, billed). Default off. */
58
+ memoryConsolidation?: pulumi.Input<boolean>;
59
+ variables?: pulumi.Input<AgentVariable[]>;
60
+ /** Release note recorded when a new version is published. */
61
+ publishNote?: pulumi.Input<string>;
62
+ /** Rollout percentage for the published version (default 100). */
63
+ rolloutPercent?: pulumi.Input<number>;
64
+ }
65
+ export declare class IcAgent extends pulumi.dynamic.Resource {
66
+ /** The `agt_…` id (same as `.id`, exposed for convenience). */
67
+ readonly agentId: pulumi.Output<string>;
68
+ /** The version number currently rolled out. */
69
+ readonly activeVersion: pulumi.Output<number>;
70
+ constructor(name: string, args: IcAgentArgs, opts?: pulumi.CustomResourceOptions);
71
+ }
72
+ export interface IcMcpServerArgs {
73
+ baseUrl: pulumi.Input<string>;
74
+ token: pulumi.Input<string>;
75
+ /** Registry name — the `{name}` in `/v1/tenant/mcp/{name}`. */
76
+ serverName: pulumi.Input<string>;
77
+ /** Server URL. Optional when `catalog` is set (the preset supplies it). */
78
+ url?: pulumi.Input<string>;
79
+ /** Stamp from a curated catalog preset (e.g. "stripe"); copied down at enable. */
80
+ catalog?: pulumi.Input<string>;
81
+ authKind?: pulumi.Input<string>;
82
+ authProvider?: pulumi.Input<string>;
83
+ /** oauth only: "tenant" (your own OAuth client) | "platform" (Ingram-owned). */
84
+ clientMode?: pulumi.Input<string>;
85
+ secret?: pulumi.Input<string>;
86
+ /** Default-deny: only these tool names reach a run. Omit to expose all discovered. */
87
+ toolAllowlist?: pulumi.Input<string[]>;
88
+ /** IC-side approval gating by tool-name glob, e.g. `[{ match: "create_refund" }]`. */
89
+ approvalPolicy?: pulumi.Input<{
90
+ match: string;
91
+ require?: string;
92
+ }[]>;
93
+ }
94
+ export declare class IcMcpServer extends pulumi.dynamic.Resource {
95
+ readonly toolsDiscovered: pulumi.Output<number>;
96
+ readonly discoveryError: pulumi.Output<string | undefined>;
97
+ constructor(name: string, args: IcMcpServerArgs, opts?: pulumi.CustomResourceOptions);
98
+ }
99
+ export interface IcTelegramBotArgs {
100
+ baseUrl: pulumi.Input<string>;
101
+ token: pulumi.Input<string>;
102
+ botToken: pulumi.Input<string>;
103
+ }
104
+ export declare class IcTelegramBot extends pulumi.dynamic.Resource {
105
+ readonly botUsername: pulumi.Output<string>;
106
+ readonly webhookUrl: pulumi.Output<string>;
107
+ constructor(name: string, args: IcTelegramBotArgs, opts?: pulumi.CustomResourceOptions);
108
+ }
109
+ export interface IcOauthProviderArgs {
110
+ baseUrl: pulumi.Input<string>;
111
+ token: pulumi.Input<string>;
112
+ /** Provider key — the `{provider}` in `/v1/tenant/providers/{provider}`;
113
+ * what an oauth-kind `IcMcpServer` names via `authProvider`. */
114
+ provider: pulumi.Input<string>;
115
+ /** Webhook-delegation mode: IC POSTs `{ connection_id }` here when a stored
116
+ * token nears expiry and the tenant PATCHes a fresh pair back. Set this
117
+ * WITHOUT `clientSecret` (a stored secret switches IC to IC-driven mode). */
118
+ refreshWebhook?: pulumi.Input<string>;
119
+ clientId?: pulumi.Input<string>;
120
+ /** IC-driven refresh mode: IC exchanges the refresh token at `tokenUri`. */
121
+ clientSecret?: pulumi.Input<string>;
122
+ tokenUri?: pulumi.Input<string>;
123
+ scopesAllowed?: pulumi.Input<string[]>;
124
+ }
125
+ export declare class IcOauthProvider extends pulumi.dynamic.Resource {
126
+ constructor(name: string, args: IcOauthProviderArgs, opts?: pulumi.CustomResourceOptions);
127
+ }
128
+ export interface IcWhatsAppConfigArgs {
129
+ baseUrl: pulumi.Input<string>;
130
+ token: pulumi.Input<string>;
131
+ phoneNumberId: pulumi.Input<string>;
132
+ accessToken: pulumi.Input<string>;
133
+ /** The WhatsApp Business Account id — IC subscribes its app to it so inbound flows. */
134
+ wabaId: pulumi.Input<string>;
135
+ }
136
+ export declare class IcWhatsAppConfig extends pulumi.dynamic.Resource {
137
+ readonly displayPhoneNumber: pulumi.Output<string>;
138
+ readonly webhookUrl: pulumi.Output<string>;
139
+ constructor(name: string, args: IcWhatsAppConfigArgs, opts?: pulumi.CustomResourceOptions);
140
+ }
141
+ export interface IcEmailArgs {
142
+ baseUrl: pulumi.Input<string>;
143
+ token: pulumi.Input<string>;
144
+ /** Cloudflare account that owns the sending domain + Email-Sending token. */
145
+ cloudflareAccountId: pulumi.Input<string>;
146
+ /** Email-Sending-scoped Cloudflare API token (secret). */
147
+ cloudflareApiToken: pulumi.Input<string>;
148
+ /** The verified sending domain (e.g. `mail.thornhill.app`). */
149
+ fromDomain: pulumi.Input<string>;
150
+ /** Optional friendly From name. */
151
+ displayName?: pulumi.Input<string>;
152
+ /** Optional HMAC for inbound-worker verification; IC generates one if omitted. */
153
+ inboundSecret?: pulumi.Input<string>;
154
+ }
155
+ export declare class IcEmail extends pulumi.dynamic.Resource {
156
+ readonly inboundSecret: pulumi.Output<string>;
157
+ readonly inboundUrl: pulumi.Output<string>;
158
+ readonly configured: pulumi.Output<boolean>;
159
+ constructor(name: string, args: IcEmailArgs, opts?: pulumi.CustomResourceOptions);
160
+ }
161
+ export interface IcWebhookArgs {
162
+ baseUrl: pulumi.Input<string>;
163
+ token: pulumi.Input<string>;
164
+ url: pulumi.Input<string>;
165
+ events: pulumi.Input<string[]>;
166
+ }
167
+ export declare class IcWebhook extends pulumi.dynamic.Resource {
168
+ /** The signing secret — present only when Pulumi created the webhook. */
169
+ readonly secret: pulumi.Output<string | undefined>;
170
+ constructor(name: string, args: IcWebhookArgs, opts?: pulumi.CustomResourceOptions);
171
+ }
172
+ export interface IcModelKeyArgs {
173
+ baseUrl: pulumi.Input<string>;
174
+ token: pulumi.Input<string>;
175
+ /** Provider id, e.g. `openai`, `anthropic`. */
176
+ provider: pulumi.Input<string>;
177
+ apiKey: pulumi.Input<string>;
178
+ /** Optional override base URL (OpenAI-compatible backends). */
179
+ providerBaseUrl?: pulumi.Input<string>;
180
+ }
181
+ export declare class IcModelKey extends pulumi.dynamic.Resource {
182
+ constructor(name: string, args: IcModelKeyArgs, opts?: pulumi.CustomResourceOptions);
183
+ }
184
+ export interface IcProjectArgs {
185
+ baseUrl: pulumi.Input<string>;
186
+ /** An **organization** key (`organization:*`), NOT a project token. */
187
+ token: pulumi.Input<string>;
188
+ /** Adopt-by-name reconcile key (defaults to the Pulumi resource name). */
189
+ projectName?: pulumi.Input<string>;
190
+ }
191
+ export declare class IcProject extends pulumi.dynamic.Resource {
192
+ /** The project id — this is the TENANT a project token is bound to. */
193
+ readonly projectId: pulumi.Output<string>;
194
+ constructor(name: string, args: IcProjectArgs, opts?: pulumi.CustomResourceOptions);
195
+ }
196
+ export interface IcProjectTokenArgs {
197
+ baseUrl: pulumi.Input<string>;
198
+ /** An **organization** key (`organization:*`), NOT a project token. */
199
+ token: pulumi.Input<string>;
200
+ /** The project id to mint for — typically an `IcProject().projectId`. */
201
+ project: pulumi.Input<string>;
202
+ tokenName?: pulumi.Input<string>;
203
+ ttlSeconds?: pulumi.Input<number>;
204
+ }
205
+ export declare class IcProjectToken extends pulumi.dynamic.Resource {
206
+ /** The minted `tenant:*` token (secret) — feed this into the app's env. */
207
+ readonly projectToken: pulumi.Output<string>;
208
+ readonly tokenId: pulumi.Output<string>;
209
+ constructor(name: string, args: IcProjectTokenArgs, opts?: pulumi.CustomResourceOptions);
210
+ }
@@ -0,0 +1,686 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.IcProjectToken = exports.IcProject = exports.IcModelKey = exports.IcWebhook = exports.IcEmail = exports.IcWhatsAppConfig = exports.IcOauthProvider = exports.IcTelegramBot = exports.IcMcpServer = exports.IcAgent = exports.IC_API_VERSION = void 0;
37
+ /**
38
+ * Ingram Cloud /v1 tenant configuration as Pulumi dynamic resources.
39
+ *
40
+ * Replaces the per-app `scripts/register-*.ts` / `ensure-agent.ts` one-shots
41
+ * with declarative resources, so IC wiring is a `pulumi up` and lives in state
42
+ * instead of drifting in someone's shell history.
43
+ *
44
+ * Each resource talks to the IC REST API. `token` is the tenant-admin bearer;
45
+ * keep it (and any credential input) a stack secret — it's marked as an
46
+ * additional secret output so it's encrypted in state.
47
+ *
48
+ * Where each resource is meant to be declared:
49
+ * - **Agents** (`IcAgent`) — the `instructions` are app-owned *content*,
50
+ * so declare these in the APP repo's Pulumi program, sourced from the app's
51
+ * own spec module. Never copy prompts into the infra repo.
52
+ * - **Integrations / tenant config** (`IcMcpServer`, `IcOauthProvider`,
53
+ * `IcTelegramBot`, `IcWhatsAppConfig`, `IcWebhook`, `IcModelKey`) —
54
+ * tenant-level wiring; declare in the infra repo alongside DNS/DB/Vercel.
55
+ *
56
+ * Not modelled here, on purpose:
57
+ * - **Per-end-user actions** — creating users, binding deployments, pushing
58
+ * per-user connection tokens, minting user tokens — happen at app runtime.
59
+ * - **Attaching existing smiths to an agent** — a one-time backfill that
60
+ * mutates the live fleet; keep it as the app's migration script. New
61
+ * smiths attach at birth in app code.
62
+ * - **Slack app factory** — config-token rotation is stateful and app-driven.
63
+ */
64
+ const pulumi = __importStar(require("@pulumi/pulumi"));
65
+ /**
66
+ * The /v1 API version this library targets. Bumping the IC API contract is a
67
+ * library version bump — consumers pin behaviour by pinning this package.
68
+ */
69
+ exports.IC_API_VERSION = "2026-05-01";
70
+ // Shared IC REST call. Module-scope so Pulumi's closure serialization captures
71
+ // it into each provider's methods; uses only the global `fetch` (Node ≥18).
72
+ async function icRequest(baseUrl, token, method, path, body, idempotencyKey) {
73
+ const headers = {
74
+ Authorization: `Bearer ${token}`,
75
+ "IC-Api-Version": exports.IC_API_VERSION,
76
+ };
77
+ if (body !== undefined)
78
+ headers["Content-Type"] = "application/json";
79
+ if (idempotencyKey)
80
+ headers["Idempotency-Key"] = idempotencyKey;
81
+ const res = await fetch(`${baseUrl}${path}`, {
82
+ method,
83
+ headers,
84
+ body: body !== undefined ? JSON.stringify(body) : undefined,
85
+ });
86
+ const text = await res.text();
87
+ if (!res.ok) {
88
+ // Carry the status so `read` can tell "gone" (404) from a transient blip.
89
+ const err = new Error(`IC ${method} ${path} → ${res.status}: ${text.slice(0, 400)}`);
90
+ err.status = res.status;
91
+ throw err;
92
+ }
93
+ return text ? JSON.parse(text) : {};
94
+ }
95
+ // Shared `diff` for the CRUD-scaffold providers: compare a fixed set of keys and
96
+ // build the Pulumi DiffResult. `replaceKeys` force a replace when changed;
97
+ // `scalarKeys` are in-place by reference equality; `listKeys` are in-place but
98
+ // compared by value (JSON). Each provider keeps its exact key set and its
99
+ // `deleteBeforeReplace` choice — this only removes the hand-rolled boilerplate.
100
+ function simpleDiff(olds, news, opts) {
101
+ const replaces = (opts.replaceKeys ?? []).filter((k) => olds[k] !== news[k]);
102
+ const scalarChanged = (opts.scalarKeys ?? []).some((k) => olds[k] !== news[k]);
103
+ const listChanged = (opts.listKeys ?? []).some((k) => JSON.stringify(olds[k] ?? null) !== JSON.stringify(news[k] ?? null));
104
+ return {
105
+ changes: replaces.length > 0 || scalarChanged || listChanged,
106
+ replaces,
107
+ deleteBeforeReplace: opts.deleteBeforeReplace,
108
+ };
109
+ }
110
+ // Shared `read` 404 handling: run a fetch, map a server-side 404 to `{ id: "" }`
111
+ // (so refresh prunes the resource and the next `up` recreates it) and rethrow any
112
+ // other error so a transient blip can't nuke state.
113
+ async function read404(fetchFn, onFound) {
114
+ try {
115
+ return onFound(await fetchFn());
116
+ }
117
+ catch (e) {
118
+ if (e?.status === 404)
119
+ return { id: "" };
120
+ throw e;
121
+ }
122
+ }
123
+ // Factory for the PUT-style providers (mcp, telegram, whatsapp, modelKey): they
124
+ // all reconcile by PUTting the desired body to a resource path and DELETE the
125
+ // same path to tear down. They differ only in (a) the path — a fixed singleton
126
+ // vs one keyed by an input field — (b) the request body, (c) which response
127
+ // fields become outputs, and (d) the diff key sets — all passed in here.
128
+ function makePutResource(cfg) {
129
+ const put = async (i) => {
130
+ const res = await icRequest(i.baseUrl, i.token, "PUT", cfg.path(i), cfg.body(i));
131
+ return { ...i, ...(cfg.outs ? cfg.outs(i, res) : {}) };
132
+ };
133
+ return {
134
+ async create(i) {
135
+ return { id: cfg.id(i), outs: await put(i) };
136
+ },
137
+ async update(_id, _olds, news) {
138
+ return { outs: await put(news) };
139
+ },
140
+ async delete(_id, props) {
141
+ await icRequest(props.baseUrl, props.token, "DELETE", cfg.path(props));
142
+ },
143
+ async diff(_id, olds, news) {
144
+ return simpleDiff(olds, news, cfg.diff);
145
+ },
146
+ };
147
+ }
148
+ // Lower-case, hyphenate, trim — matches the API's slug derivation so a
149
+ // logical-name-derived slug lines up with what the server would compute.
150
+ function slugify(s) {
151
+ return (s
152
+ .toLowerCase()
153
+ .replace(/[^a-z0-9]+/g, "-")
154
+ .replace(/^-+|-+$/g, "")
155
+ .slice(0, 63) || "agent");
156
+ }
157
+ // The draft body IC's create/patch expect (snake_case wire shape). `slug` is
158
+ // honoured on create and ignored on patch (it's immutable), so the display
159
+ // `name` defaults to the slug when none is given.
160
+ function agentBody(i) {
161
+ return {
162
+ slug: i.slug,
163
+ name: i.name ?? i.slug,
164
+ instructions: i.instructions ?? null,
165
+ model: i.model ?? null,
166
+ enabled_hosted_tools: i.enabledHostedTools ?? [],
167
+ auto_memory: i.autoMemory ?? null,
168
+ memory_consolidation: i.memoryConsolidation ?? null,
169
+ variables: i.variables ?? [],
170
+ };
171
+ }
172
+ // Stable signature of the *content* that warrants a new published version
173
+ // (name and rollout% deliberately excluded — a rename/restage is not new content).
174
+ function agentContentSig(s) {
175
+ return JSON.stringify({
176
+ instructions: s.instructions ?? null,
177
+ model: s.model ?? null,
178
+ tools: [...(s.enabled_hosted_tools ?? [])].sort(),
179
+ auto_memory: s.auto_memory ?? null,
180
+ memory_consolidation: s.memory_consolidation ?? null,
181
+ variables: s.variables ?? [],
182
+ });
183
+ }
184
+ // Map the camelCase resource inputs onto the canonical snapshot shape.
185
+ function inputsToSnapshot(i) {
186
+ return {
187
+ instructions: i.instructions,
188
+ model: i.model,
189
+ enabled_hosted_tools: i.enabledHostedTools,
190
+ auto_memory: i.autoMemory,
191
+ memory_consolidation: i.memoryConsolidation,
192
+ variables: i.variables,
193
+ };
194
+ }
195
+ async function findAgentBySlug(baseUrl, token, slug) {
196
+ const res = await icRequest(baseUrl, token, "GET", "/v1/agents");
197
+ return (res.data ?? []).find((b) => b.slug === slug) ?? null;
198
+ }
199
+ // Signature of the snapshot currently published as the active version, or null
200
+ // if nothing is published yet.
201
+ async function activeSnapshotSig(baseUrl, token, id, activeVersion) {
202
+ if (!activeVersion)
203
+ return null;
204
+ const res = await icRequest(baseUrl, token, "GET", `/v1/agents/${id}/versions`);
205
+ const v = (res.data ?? []).find((x) => x.version === activeVersion);
206
+ if (!v)
207
+ return null;
208
+ // The version's `snapshot` is already the canonical snake_case shape.
209
+ return agentContentSig(v.snapshot ?? {});
210
+ }
211
+ // Ensure the live agent matches desired: adopt-or-create, publish iff the
212
+ // content drifted from the active version, then roll out at `percent`.
213
+ async function reconcileAgent(i, knownId, priorSig) {
214
+ let id;
215
+ if (!knownId) {
216
+ const existing = await findAgentBySlug(i.baseUrl, i.token, i.slug);
217
+ if (existing) {
218
+ id = existing.id;
219
+ await icRequest(i.baseUrl, i.token, "PATCH", `/v1/agents/${id}`, agentBody(i));
220
+ }
221
+ else {
222
+ // No idempotency key: Pulumi calls create once and tracks the id in
223
+ // state, and the adopt-by-slug branch above covers re-runs and the
224
+ // script→Pulumi migration. Keying on the slug would replay a stale
225
+ // (possibly archived) create after a destroy+recreate within the 24h
226
+ // idempotency window.
227
+ const bp = await icRequest(i.baseUrl, i.token, "POST", "/v1/agents", agentBody(i));
228
+ id = bp.id;
229
+ }
230
+ }
231
+ else {
232
+ // Known id (update path): push the draft to the desired shape.
233
+ id = knownId;
234
+ await icRequest(i.baseUrl, i.token, "PATCH", `/v1/agents/${id}`, agentBody(i));
235
+ }
236
+ const bp = await icRequest(i.baseUrl, i.token, "GET", `/v1/agents/${id}`);
237
+ const desiredSig = agentContentSig(inputsToSnapshot(i));
238
+ // On the create path we don't know the prior sig — compare to what's actually
239
+ // published so adopting a script-made agent doesn't churn a needless version.
240
+ const liveSig = priorSig ??
241
+ (await activeSnapshotSig(i.baseUrl, i.token, id, bp.active_version));
242
+ let version = bp.active_version ?? 0;
243
+ if (!bp.active_version || liveSig !== desiredSig) {
244
+ // Publishing snapshots the draft as the next immutable version and returns
245
+ // its number. Only the *first* publish auto-activates; later ones go live
246
+ // only via the rollout below — so take the version from the publish reply,
247
+ // not from active_version (which hasn't advanced yet).
248
+ const pub = await icRequest(i.baseUrl, i.token, "POST", `/v1/agents/${id}/versions`, {
249
+ note: i.publishNote ?? null,
250
+ });
251
+ version = pub.version;
252
+ }
253
+ // Always assert the rollout: it activates a freshly published version and is
254
+ // an idempotent no-op when `version` is already the active one at `percent`.
255
+ const percent = i.rolloutPercent ?? 100;
256
+ await icRequest(i.baseUrl, i.token, "POST", `/v1/agents/${id}/rollout`, {
257
+ version,
258
+ percent,
259
+ });
260
+ return {
261
+ ...i,
262
+ agentId: id,
263
+ contentSig: desiredSig,
264
+ activeVersion: version,
265
+ rolloutPercent: percent,
266
+ };
267
+ }
268
+ const agentProvider = {
269
+ async create(i) {
270
+ const outs = await reconcileAgent(i);
271
+ return { id: outs.agentId, outs };
272
+ },
273
+ async update(id, olds, news) {
274
+ const outs = await reconcileAgent(news, id, olds.contentSig);
275
+ return { outs };
276
+ },
277
+ async delete(id, props) {
278
+ try {
279
+ await icRequest(props.baseUrl, props.token, "DELETE", `/v1/agents/${id}`);
280
+ }
281
+ catch (e) {
282
+ // A 409 is the API's `agent_in_use` precondition: an agent can't be archived
283
+ // while smiths still run it. Surface that as an actionable message; the API's
284
+ // own error (carrying the smith count) is appended. Any other error propagates
285
+ // unwrapped.
286
+ if (e?.status === 409)
287
+ throw new Error(`Agent ${id} can't be archived while smiths still run it. Delete or ` +
288
+ `re-point those smiths first, or set { retainOnDelete: true } on the ` +
289
+ `resource to leave the agent in place. ${e}`);
290
+ throw e;
291
+ }
292
+ },
293
+ async diff(_id, olds, news) {
294
+ // slug is the immutable reconcile key — changing it is a replace, not an
295
+ // update (the old and new slugs differ, so create-before-delete is safe).
296
+ if (olds.slug !== news.slug)
297
+ return { changes: true, replaces: ["slug"] };
298
+ const contentChanged = agentContentSig(inputsToSnapshot(olds)) !==
299
+ agentContentSig(inputsToSnapshot(news));
300
+ const nameChanged = (olds.name ?? olds.slug) !== (news.name ?? news.slug);
301
+ const rolloutChanged = (olds.rolloutPercent ?? 100) !== (news.rolloutPercent ?? 100);
302
+ return { changes: contentChanged || nameChanged || rolloutChanged };
303
+ },
304
+ // Adopt a agent a script already created (`pulumi import <id>`).
305
+ async read(id, props) {
306
+ return read404(() => icRequest(props.baseUrl, props.token, "GET", `/v1/agents/${id}`), (bp) => {
307
+ if (!bp || !bp.id)
308
+ return { id: "" };
309
+ const d = bp.draft ?? {};
310
+ return {
311
+ id,
312
+ props: {
313
+ ...props,
314
+ slug: bp.slug ?? props.slug,
315
+ name: bp.name,
316
+ instructions: d.instructions ?? undefined,
317
+ model: d.model ?? undefined,
318
+ enabledHostedTools: d.enabled_hosted_tools ?? [],
319
+ autoMemory: d.auto_memory ?? undefined,
320
+ memoryConsolidation: d.memory_consolidation ?? undefined,
321
+ variables: d.variables ?? [],
322
+ },
323
+ };
324
+ });
325
+ },
326
+ };
327
+ class IcAgent extends pulumi.dynamic.Resource {
328
+ constructor(name, args, opts) {
329
+ super(agentProvider, name, {
330
+ instructions: undefined,
331
+ model: undefined,
332
+ enabledHostedTools: undefined,
333
+ autoMemory: undefined,
334
+ memoryConsolidation: undefined,
335
+ variables: undefined,
336
+ publishNote: undefined,
337
+ rolloutPercent: undefined,
338
+ agentId: undefined,
339
+ activeVersion: undefined,
340
+ contentSig: undefined,
341
+ ...args,
342
+ // Default the reconcile key to the (stable) resource name when the
343
+ // caller doesn't pin one — kept after ...args so it always resolves.
344
+ slug: args.slug ?? slugify(name),
345
+ }, { ...opts, additionalSecretOutputs: ["token"] });
346
+ }
347
+ }
348
+ exports.IcAgent = IcAgent;
349
+ function mcpBody(i) {
350
+ const auth = { kind: i.authKind };
351
+ if (i.authProvider)
352
+ auth.provider = i.authProvider;
353
+ if (i.clientMode)
354
+ auth.client_mode = i.clientMode;
355
+ if (i.secret)
356
+ auth.secret = i.secret;
357
+ const body = { auth };
358
+ if (i.url)
359
+ body.url = i.url;
360
+ if (i.catalog)
361
+ body.catalog = i.catalog;
362
+ if (i.toolAllowlist !== undefined)
363
+ body.tool_allowlist = i.toolAllowlist;
364
+ if (i.approvalPolicy !== undefined)
365
+ body.approval_policy = i.approvalPolicy;
366
+ return body;
367
+ }
368
+ const mcpProvider = makePutResource({
369
+ path: (i) => `/v1/tenant/mcp/${encodeURIComponent(i.serverName)}`,
370
+ id: (i) => i.serverName,
371
+ body: mcpBody,
372
+ outs: (_i, res) => ({
373
+ toolsDiscovered: res.tools_discovered ?? 0,
374
+ discoveryError: res.discovery_error ?? null,
375
+ }),
376
+ diff: {
377
+ replaceKeys: ["serverName"],
378
+ scalarKeys: [
379
+ "url",
380
+ "catalog",
381
+ "authKind",
382
+ "authProvider",
383
+ "clientMode",
384
+ "secret",
385
+ "token",
386
+ "baseUrl",
387
+ ],
388
+ // Arrays compare by value, not reference.
389
+ listKeys: ["toolAllowlist", "approvalPolicy"],
390
+ deleteBeforeReplace: true,
391
+ },
392
+ });
393
+ class IcMcpServer extends pulumi.dynamic.Resource {
394
+ constructor(name, args, opts) {
395
+ super(mcpProvider, name, {
396
+ authKind: args.catalog ? undefined : "static",
397
+ url: undefined,
398
+ catalog: undefined,
399
+ authProvider: undefined,
400
+ clientMode: undefined,
401
+ secret: undefined,
402
+ toolAllowlist: undefined,
403
+ approvalPolicy: undefined,
404
+ toolsDiscovered: undefined,
405
+ discoveryError: undefined,
406
+ ...args,
407
+ }, { ...opts, additionalSecretOutputs: ["token", "secret"] });
408
+ }
409
+ }
410
+ exports.IcMcpServer = IcMcpServer;
411
+ const telegramProvider = makePutResource({
412
+ path: () => "/v1/tenant/telegram",
413
+ id: () => "telegram",
414
+ body: (i) => ({ bot_token: i.botToken }),
415
+ outs: (_i, res) => ({
416
+ botUsername: res.bot_username,
417
+ botId: res.bot_id,
418
+ webhookUrl: res.webhook_url,
419
+ }),
420
+ diff: { scalarKeys: ["botToken", "token", "baseUrl"] },
421
+ });
422
+ class IcTelegramBot extends pulumi.dynamic.Resource {
423
+ constructor(name, args, opts) {
424
+ super(telegramProvider, name, {
425
+ botUsername: undefined,
426
+ botId: undefined,
427
+ webhookUrl: undefined,
428
+ ...args,
429
+ }, { ...opts, additionalSecretOutputs: ["token", "botToken"] });
430
+ }
431
+ }
432
+ exports.IcTelegramBot = IcTelegramBot;
433
+ const oauthProviderResource = makePutResource({
434
+ path: (i) => `/v1/tenant/providers/${encodeURIComponent(i.provider)}`,
435
+ id: (i) => i.provider,
436
+ body: (i) => ({
437
+ refresh_webhook: i.refreshWebhook,
438
+ client_id: i.clientId,
439
+ // Key presence is meaningful on the wire: an omitted `client_secret`
440
+ // keeps the stored one, a sent value (even null) replaces it. Only send
441
+ // the key when the input is set so a webhook-mode provider never
442
+ // clobbers— or phantom-clears — a secret.
443
+ ...(i.clientSecret !== undefined ? { client_secret: i.clientSecret } : {}),
444
+ token_uri: i.tokenUri,
445
+ scopes_allowed: i.scopesAllowed,
446
+ }),
447
+ diff: {
448
+ replaceKeys: ["provider"],
449
+ scalarKeys: [
450
+ "refreshWebhook",
451
+ "clientId",
452
+ "clientSecret",
453
+ "tokenUri",
454
+ "token",
455
+ "baseUrl",
456
+ ],
457
+ listKeys: ["scopesAllowed"],
458
+ deleteBeforeReplace: true,
459
+ },
460
+ });
461
+ class IcOauthProvider extends pulumi.dynamic.Resource {
462
+ constructor(name, args, opts) {
463
+ super(oauthProviderResource, name, {
464
+ refreshWebhook: undefined,
465
+ clientId: undefined,
466
+ clientSecret: undefined,
467
+ tokenUri: undefined,
468
+ scopesAllowed: undefined,
469
+ ...args,
470
+ }, { ...opts, additionalSecretOutputs: ["token", "clientSecret"] });
471
+ }
472
+ }
473
+ exports.IcOauthProvider = IcOauthProvider;
474
+ const whatsappProvider = makePutResource({
475
+ path: () => "/v1/tenant/whatsapp",
476
+ id: () => "whatsapp",
477
+ body: (i) => ({
478
+ phone_number_id: i.phoneNumberId,
479
+ access_token: i.accessToken,
480
+ waba_id: i.wabaId,
481
+ }),
482
+ outs: (_i, res) => ({
483
+ displayPhoneNumber: res.display_phone_number,
484
+ webhookUrl: res.webhook_url,
485
+ }),
486
+ diff: {
487
+ scalarKeys: ["phoneNumberId", "accessToken", "wabaId", "token", "baseUrl"],
488
+ },
489
+ });
490
+ class IcWhatsAppConfig extends pulumi.dynamic.Resource {
491
+ constructor(name, args, opts) {
492
+ super(whatsappProvider, name, {
493
+ displayPhoneNumber: undefined,
494
+ webhookUrl: undefined,
495
+ ...args,
496
+ }, {
497
+ ...opts,
498
+ additionalSecretOutputs: ["token", "accessToken"],
499
+ });
500
+ }
501
+ }
502
+ exports.IcWhatsAppConfig = IcWhatsAppConfig;
503
+ const emailProvider = makePutResource({
504
+ path: () => "/v1/tenant/email",
505
+ id: () => "email",
506
+ body: (i) => ({
507
+ cloudflare_account_id: i.cloudflareAccountId,
508
+ cloudflare_api_token: i.cloudflareApiToken,
509
+ from_domain: i.fromDomain,
510
+ display_name: i.displayName,
511
+ inbound_secret: i.inboundSecret,
512
+ }),
513
+ outs: (i, res) => ({
514
+ // IC generates inbound_secret when we don't supply one — capture it so it's
515
+ // stable in state (and wireable into the inbound worker).
516
+ inboundSecret: res.inbound_secret ?? i.inboundSecret,
517
+ inboundUrl: res.inbound_url,
518
+ configured: res.configured,
519
+ }),
520
+ // inboundSecret excluded — IC may generate it, so it's an output, not drift.
521
+ diff: {
522
+ scalarKeys: [
523
+ "cloudflareAccountId",
524
+ "cloudflareApiToken",
525
+ "fromDomain",
526
+ "displayName",
527
+ "token",
528
+ "baseUrl",
529
+ ],
530
+ },
531
+ });
532
+ class IcEmail extends pulumi.dynamic.Resource {
533
+ constructor(name, args, opts) {
534
+ super(emailProvider, name, {
535
+ displayName: undefined,
536
+ inboundSecret: undefined,
537
+ inboundUrl: undefined,
538
+ configured: undefined,
539
+ ...args,
540
+ }, {
541
+ ...opts,
542
+ additionalSecretOutputs: [
543
+ "token",
544
+ "cloudflareApiToken",
545
+ "inboundSecret",
546
+ ],
547
+ });
548
+ }
549
+ }
550
+ exports.IcEmail = IcEmail;
551
+ const webhookProvider = {
552
+ async create(i) {
553
+ const res = await icRequest(i.baseUrl, i.token, "POST", "/v1/tenant/webhooks", {
554
+ url: i.url,
555
+ events: i.events,
556
+ active: true,
557
+ });
558
+ return { id: res.id, outs: { ...i, secret: res.secret } };
559
+ },
560
+ async update(id, olds, news) {
561
+ await icRequest(news.baseUrl, news.token, "PATCH", `/v1/tenant/webhooks/${id}`, {
562
+ events: news.events,
563
+ });
564
+ // The signing secret survives an events PATCH on IC's side; carry the
565
+ // create-time output forward so an update never blanks consumers that
566
+ // flow `secret` into app config.
567
+ return { outs: { ...news, secret: olds.secret } };
568
+ },
569
+ async delete(id, props) {
570
+ await icRequest(props.baseUrl, props.token, "DELETE", `/v1/tenant/webhooks/${id}`);
571
+ },
572
+ async diff(_id, olds, news) {
573
+ const replaces = olds.url !== news.url ? ["url"] : [];
574
+ const eventsChanged = JSON.stringify([...(olds.events ?? [])].sort()) !==
575
+ JSON.stringify([...(news.events ?? [])].sort());
576
+ return { changes: replaces.length > 0 || eventsChanged, replaces };
577
+ },
578
+ // Enables `pulumi import` of a webhook a script already created (id is the
579
+ // import id). The signing secret is intentionally not recoverable.
580
+ async read(id, props) {
581
+ const list = await icRequest(props.baseUrl, props.token, "GET", "/v1/tenant/webhooks");
582
+ const hit = (list.data ?? []).find((w) => w.id === id);
583
+ // Absent from the list → gone server-side; empty id lets refresh prune it.
584
+ if (!hit)
585
+ return { id: "" };
586
+ return { id, props: { ...props, url: hit.url, events: hit.events } };
587
+ },
588
+ };
589
+ class IcWebhook extends pulumi.dynamic.Resource {
590
+ constructor(name, args, opts) {
591
+ super(webhookProvider, name, { secret: undefined, ...args }, {
592
+ ...opts,
593
+ additionalSecretOutputs: ["token", "secret"],
594
+ });
595
+ }
596
+ }
597
+ exports.IcWebhook = IcWebhook;
598
+ const modelKeyProvider = makePutResource({
599
+ path: (i) => `/v1/tenant/model_keys/${encodeURIComponent(i.provider)}`,
600
+ id: (i) => i.provider,
601
+ body: (i) => ({ api_key: i.apiKey, base_url: i.providerBaseUrl ?? null }),
602
+ diff: {
603
+ replaceKeys: ["provider"],
604
+ scalarKeys: ["apiKey", "providerBaseUrl", "token", "baseUrl"],
605
+ deleteBeforeReplace: true,
606
+ },
607
+ });
608
+ class IcModelKey extends pulumi.dynamic.Resource {
609
+ constructor(name, args, opts) {
610
+ super(modelKeyProvider, name, { providerBaseUrl: undefined, ...args }, { ...opts, additionalSecretOutputs: ["token", "apiKey"] });
611
+ }
612
+ }
613
+ exports.IcModelKey = IcModelKey;
614
+ async function findProjectByName(baseUrl, token, name) {
615
+ const res = await icRequest(baseUrl, token, "GET", `/v1/organization/projects?name=${encodeURIComponent(name)}`);
616
+ return (res.data ?? [])[0] ?? null;
617
+ }
618
+ const projectProvider = {
619
+ async create(i) {
620
+ const existing = await findProjectByName(i.baseUrl, i.token, i.projectName);
621
+ const proj = existing ??
622
+ (await icRequest(i.baseUrl, i.token, "POST", "/v1/organization/projects", {
623
+ name: i.projectName,
624
+ }));
625
+ return { id: proj.id, outs: { ...i, projectId: proj.id } };
626
+ },
627
+ async update(id, _olds, news) {
628
+ // Nothing mutable server-side (a name change is a replace); just re-assert.
629
+ return { outs: { ...news, projectId: id } };
630
+ },
631
+ async delete(id, props) {
632
+ await icRequest(props.baseUrl, props.token, "DELETE", `/v1/organization/projects/${encodeURIComponent(id)}`);
633
+ },
634
+ async diff(_id, olds, news) {
635
+ return simpleDiff(olds, news, {
636
+ replaceKeys: ["projectName"],
637
+ scalarKeys: ["token", "baseUrl"],
638
+ deleteBeforeReplace: false,
639
+ });
640
+ },
641
+ async read(id, props) {
642
+ return read404(() => icRequest(props.baseUrl, props.token, "GET", `/v1/organization/projects/${encodeURIComponent(id)}`), (p) => ({
643
+ id,
644
+ props: { ...props, projectName: p.name, projectId: p.id },
645
+ }));
646
+ },
647
+ };
648
+ class IcProject extends pulumi.dynamic.Resource {
649
+ constructor(name, args, opts) {
650
+ super(projectProvider, name, { ...args, projectName: args.projectName ?? name, projectId: undefined }, { ...opts, additionalSecretOutputs: ["token"] });
651
+ }
652
+ }
653
+ exports.IcProject = IcProject;
654
+ const projectTokenProvider = {
655
+ async create(i) {
656
+ const res = await icRequest(i.baseUrl, i.token, "POST", `/v1/organization/projects/${encodeURIComponent(i.project)}/tokens`, { name: i.tokenName, ttl_seconds: i.ttlSeconds });
657
+ return {
658
+ id: res.id,
659
+ outs: { ...i, tokenId: res.id, projectToken: res.token, sub: res.sub },
660
+ };
661
+ },
662
+ async delete(id, props) {
663
+ await icRequest(props.baseUrl, props.token, "DELETE", `/v1/organization/projects/${encodeURIComponent(props.project)}/tokens/${encodeURIComponent(id)}`);
664
+ },
665
+ async diff(_id, olds, news) {
666
+ // The token value can't be edited in place — any change re-mints. Mint the
667
+ // new one before revoking the old so dependents can switch over first
668
+ // (so every input is a replace key, none in-place).
669
+ return simpleDiff(olds, news, {
670
+ replaceKeys: ["project", "tokenName", "ttlSeconds", "token", "baseUrl"],
671
+ deleteBeforeReplace: false,
672
+ });
673
+ },
674
+ };
675
+ class IcProjectToken extends pulumi.dynamic.Resource {
676
+ constructor(name, args, opts) {
677
+ super(projectTokenProvider, name, {
678
+ ...args,
679
+ tokenName: args.tokenName ?? name,
680
+ tokenId: undefined,
681
+ projectToken: undefined,
682
+ sub: undefined,
683
+ }, { ...opts, additionalSecretOutputs: ["token", "projectToken"] });
684
+ }
685
+ }
686
+ exports.IcProjectToken = IcProjectToken;
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@ingram-cloud/pulumi",
3
+ "version": "1.0.0",
4
+ "description": "Pulumi dynamic resources for the Ingram Cloud /v1 API — projects, project tokens, agents, MCP servers, channels, webhooks, model keys.",
5
+ "license": "MIT",
6
+ "homepage": "https://cloud.ingram.tech",
7
+ "keywords": [
8
+ "pulumi",
9
+ "ingram-cloud",
10
+ "ingram"
11
+ ],
12
+ "main": "dist/index.js",
13
+ "types": "dist/index.d.ts",
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.json",
22
+ "type-check": "tsc -p tsconfig.json --noEmit",
23
+ "lint": "oxlint",
24
+ "format": "oxfmt --write .",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "peerDependencies": {
28
+ "@pulumi/pulumi": "^3.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@ingram-tech/oxlint-config": "^0.2.1",
32
+ "@pulumi/pulumi": "^3.0.0",
33
+ "@types/node": "^25.0.0",
34
+ "oxfmt": "^0.54.0",
35
+ "oxlint": "^1.70.0",
36
+ "typescript": "^6.0.0"
37
+ }
38
+ }