@ingram-cloud/pulumi 1.5.0 → 2.1.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 CHANGED
@@ -145,15 +145,50 @@ export const pageUrl = page.pageUrl; // https://cloud.ingram.tech/hosted/dep_…
145
145
  > change to any of those (or to `agentId`) **replaces** the page and mints a fresh
146
146
  > `pageUrl`. The agent's instructions change on the `IcAgent`, not here.
147
147
 
148
+ ### IcSkill (declare in the APP repo)
149
+
150
+ An [Agent Skills](https://cloud.ingram.tech/docs/skills) bundle: a folder
151
+ anchored by `SKILL.md`, uploaded whole and versioned server-side. Declared by
152
+ **local directory** — the folder is the unit — like `IcAgent`'s `uiTemplates`
153
+ and the vector-store-shaped file attachments elsewhere in this pattern. The
154
+ bytes are read at `pulumi up`; any edit inside the folder publishes a new
155
+ immutable version and moves `default_version` to it, because a version is
156
+ immutable and nothing resolving by default would otherwise see the change.
157
+
158
+ ```ts
159
+ // The folder is the unit. Its basename is the skill's name, and any edit
160
+ // inside it publishes a new immutable version and moves the default.
161
+ const vatFiling = new ic.IcSkill("vat-filing", {
162
+ ...conn,
163
+ path: join(__dirname, "skills/vat-filing"),
164
+ });
165
+
166
+ // Put it in front of a fleet. Order matters: a run that fills its search index
167
+ // keeps the earlier entries and drops the rest.
168
+ const biller = new ic.IcAgent("biller", {
169
+ ...conn,
170
+ instructions: AGENT_SPECS.biller.instructions,
171
+ skills: [{ skill_id: vatFiling.skillId }],
172
+ // A skill's scripts/ only run through the run_command hosted tool.
173
+ enabledHostedTools: ["run_command"],
174
+ });
175
+ ```
176
+
148
177
  ### IcMcpServer, IcOauthProvider, IcTelegramBot, IcWhatsAppConfig, IcEmail, IcWebhook, IcModelKey (declare in INFRA)
149
178
 
150
179
  ```ts
151
180
  // Your own server (raw URL).
152
181
  new ic.IcMcpServer("thornhill-mcp", {
153
182
  ...conn, serverName: "thornhill", url: `${APP_URL}/api/mcp`,
154
- authKind: "static", secret: mcpSecret,
183
+ authKind: "bearer", secret: mcpSecret,
155
184
  });
156
185
 
186
+ // The tool list is discovered at create, not on every `pulumi up` — nothing in
187
+ // this stack changes when your *server's* tools do, so `toolsDiscovered` is what
188
+ // was true at registration. Ingram Cloud re-discovers on the run path once a
189
+ // manifest is an hour old; call `POST /v1/tenant/mcp/{serverName}/refresh` at the
190
+ // end of a deploy that changed your tools if an hour is too long to wait.
191
+
157
192
  // An `oauth`-kind server forwards each end-user's own stored token; its
158
193
  // provider record tells IC how tokens refresh. Webhook-delegation mode (a
159
194
  // refreshWebhook and NO clientSecret): IC POSTs { connection_id } to the
@@ -37,6 +37,13 @@ export interface AgentVariable {
37
37
  description?: string | null;
38
38
  required?: boolean;
39
39
  }
40
+ /** One skill reference in an agent's `skills` array. Omitted `version` follows
41
+ * the skill's `default_version` and is frozen at publish. Pair with
42
+ * {@link IcSkill}'s `skillId` output. */
43
+ export interface AgentSkillRef {
44
+ skillId: string;
45
+ version?: number;
46
+ }
40
47
  /** CSP domain allowlists for the sandboxed iframe (the `_meta.ui.csp` shape). */
41
48
  export interface UiCsp {
42
49
  connectDomains?: string[];
@@ -97,6 +104,15 @@ export interface IcAgentArgs {
97
104
  /** Opt into observational-memory consolidation (background, billed). Default off. */
98
105
  memoryConsolidation?: pulumi.Input<boolean>;
99
106
  variables?: pulumi.Input<AgentVariable[]>;
107
+ /**
108
+ * Skills (`skl_…`) attached to the agent, in order. Order is not cosmetic: a
109
+ * run whose skills together exceed the search-index ceiling keeps them in
110
+ * this order and drops the rest, so it is the survival priority. Folds into
111
+ * the content signature unsorted, unlike `enabledHostedTools`/`vectorStoreIds`
112
+ * — reordering is itself a content change. Pair with {@link IcSkill}'s
113
+ * `skillId` output.
114
+ */
115
+ skills?: pulumi.Input<AgentSkillRef[]>;
100
116
  /**
101
117
  * MCP Apps UI templates attached to the agent. Each template's HTML bundle is
102
118
  * uploaded when its bytes or metadata change, and its name + content hash +
@@ -117,6 +133,23 @@ export declare class IcAgent extends pulumi.dynamic.Resource {
117
133
  readonly activeVersion: pulumi.Output<number>;
118
134
  constructor(name: string, args: IcAgentArgs, opts?: pulumi.CustomResourceOptions);
119
135
  }
136
+ export interface IcSkillArgs {
137
+ baseUrl: pulumi.Input<string>;
138
+ token: pulumi.Input<string>;
139
+ /**
140
+ * Absolute path to the skill's directory — the folder anchored by `SKILL.md`,
141
+ * whose basename is the skill's name. Read at `pulumi up`; edit any file in it
142
+ * and a new immutable version is published and made the default.
143
+ */
144
+ path: pulumi.Input<string>;
145
+ }
146
+ export declare class IcSkill extends pulumi.dynamic.Resource {
147
+ /** The `skl_…` id (same as `.id`, exposed for convenience). */
148
+ readonly skillId: pulumi.Output<string>;
149
+ /** The version this stack last published, and the current `default_version`. */
150
+ readonly version: pulumi.Output<number>;
151
+ constructor(name: string, args: IcSkillArgs, opts?: pulumi.CustomResourceOptions);
152
+ }
120
153
  /** One source file on an {@link IcVectorStore}, provided by local path. */
121
154
  export interface VectorStoreFile {
122
155
  /** Absolute path to the file to ingest (read at `pulumi up`, like `htmlPath`). */
package/dist/resources.js CHANGED
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.IcWebPage = exports.IcProjectToken = exports.IcProject = exports.IcModelKey = exports.IcWebhook = exports.IcEmail = exports.IcWhatsAppConfig = exports.IcOauthProvider = exports.IcTelegramBot = exports.IcMcpServer = exports.IcVectorStore = exports.IcAgent = exports.IC_API_VERSION = void 0;
36
+ exports.IcWebPage = exports.IcProjectToken = exports.IcProject = exports.IcModelKey = exports.IcWebhook = exports.IcEmail = exports.IcWhatsAppConfig = exports.IcOauthProvider = exports.IcTelegramBot = exports.IcMcpServer = exports.IcVectorStore = exports.IcSkill = exports.IcAgent = exports.IC_API_VERSION = void 0;
37
37
  /**
38
38
  * Ingram Cloud /v1 tenant configuration as Pulumi dynamic resources.
39
39
  *
@@ -248,6 +248,10 @@ function agentBody(i) {
248
248
  auto_memory: i.autoMemory ?? null,
249
249
  memory_consolidation: i.memoryConsolidation ?? null,
250
250
  variables: i.variables ?? [],
251
+ skills: (i.skills ?? []).map((s) => ({
252
+ skill_id: s.skillId,
253
+ ...(s.version != null ? { version: s.version } : {}),
254
+ })),
251
255
  };
252
256
  }
253
257
  // Canonical, name-sorted signature of an agent's frozen UI templates: each
@@ -291,6 +295,12 @@ function agentContentSig(s) {
291
295
  const vs = [...(s.vector_store_ids ?? [])].sort();
292
296
  if (vs.length)
293
297
  base.vector_store_ids = vs;
298
+ // Skills, deliberately NOT sorted: reference order is the run's index-ceiling
299
+ // drop priority, so reordering the array is itself a content change worth a
300
+ // new version, not a no-op like reordering hosted tools or vector stores is.
301
+ const skills = s.skills ?? [];
302
+ if (skills.length)
303
+ base.skills = skills;
294
304
  return JSON.stringify(base);
295
305
  }
296
306
  // Map the camelCase resource inputs onto the canonical snapshot shape.
@@ -304,6 +314,10 @@ function inputsToSnapshot(i) {
304
314
  auto_memory: i.autoMemory,
305
315
  memory_consolidation: i.memoryConsolidation,
306
316
  variables: i.variables,
317
+ skills: (i.skills ?? []).map((s) => ({
318
+ skill_id: s.skillId,
319
+ ...(s.version != null ? { version: s.version } : {}),
320
+ })),
307
321
  // Resolve each template's bytes and hash so a HTML edit shows up in the
308
322
  // signature (the API's `content_hash` uses the same sha256-hex).
309
323
  ui_resources: resolveUiTemplates(i.uiTemplates).map((t) => ({
@@ -316,7 +330,7 @@ function inputsToSnapshot(i) {
316
330
  };
317
331
  }
318
332
  async function findAgentBySlug(baseUrl, token, slug) {
319
- const res = await icRequest(baseUrl, token, "GET", "/v1/agents");
333
+ const res = await icRequest(baseUrl, token, "GET", "/v1/agents?limit=200");
320
334
  return (res.data ?? []).find((b) => b.slug === slug) ?? null;
321
335
  }
322
336
  // Signature of the snapshot currently published as the active version, or null
@@ -324,7 +338,7 @@ async function findAgentBySlug(baseUrl, token, slug) {
324
338
  async function activeSnapshotSig(baseUrl, token, id, activeVersion) {
325
339
  if (!activeVersion)
326
340
  return null;
327
- const res = await icRequest(baseUrl, token, "GET", `/v1/agents/${id}/versions`);
341
+ const res = await icRequest(baseUrl, token, "GET", `/v1/agents/${id}/versions?limit=200`);
328
342
  const v = (res.data ?? []).find((x) => x.version === activeVersion);
329
343
  if (!v)
330
344
  return null;
@@ -451,6 +465,10 @@ const agentProvider = {
451
465
  autoMemory: d.auto_memory ?? undefined,
452
466
  memoryConsolidation: d.memory_consolidation ?? undefined,
453
467
  variables: d.variables ?? [],
468
+ skills: (d.skills ?? []).map((s) => ({
469
+ skillId: s.skill_id,
470
+ version: s.version,
471
+ })),
454
472
  },
455
473
  };
456
474
  });
@@ -467,6 +485,7 @@ class IcAgent extends pulumi.dynamic.Resource {
467
485
  autoMemory: undefined,
468
486
  memoryConsolidation: undefined,
469
487
  variables: undefined,
488
+ skills: undefined,
470
489
  uiTemplates: undefined,
471
490
  publishNote: undefined,
472
491
  rolloutPercent: undefined,
@@ -481,6 +500,128 @@ class IcAgent extends pulumi.dynamic.Resource {
481
500
  }
482
501
  }
483
502
  exports.IcAgent = IcAgent;
503
+ /** Walk a skill directory into the parts the upload sends.
504
+ *
505
+ * `require` inline rather than a top-level import: Pulumi serializes a dynamic
506
+ * provider's closure, and this is the idiom the vector-store resource next door
507
+ * already uses to read local files. */
508
+ function resolveSkillFiles(dir) {
509
+ if (!dir)
510
+ throw new Error("skill: `path` is required");
511
+ const fs = require("node:fs");
512
+ const nodePath = require("node:path");
513
+ const root = nodePath.basename(nodePath.resolve(dir));
514
+ const out = [];
515
+ const walk = (at, prefix) => {
516
+ for (const entry of fs.readdirSync(at, { withFileTypes: true })) {
517
+ const full = nodePath.join(at, entry.name);
518
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
519
+ if (entry.isDirectory())
520
+ walk(full, rel);
521
+ else if (entry.isFile())
522
+ out.push({ rel: `${root}/${rel}`, bytes: fs.readFileSync(full) });
523
+ }
524
+ };
525
+ walk(nodePath.resolve(dir), "");
526
+ if (!out.some((f) => f.rel === `${root}/SKILL.md`))
527
+ throw new Error(`skill: ${dir} has no SKILL.md`);
528
+ return out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
529
+ }
530
+ /** A content signature over the whole bundle — what `diff` compares. */
531
+ function skillSig(dir) {
532
+ if (!dir)
533
+ return "";
534
+ const { createHash } = require("node:crypto");
535
+ const hash = createHash("sha256");
536
+ for (const file of resolveSkillFiles(dir)) {
537
+ hash.update(file.rel);
538
+ hash.update(file.bytes);
539
+ }
540
+ return hash.digest("hex");
541
+ }
542
+ /** Upload a bundle as repeated `files[]` parts. Not `icRequest` (that
543
+ * JSON-encodes); uses global FormData/File (Node ≥18), like `uploadFile`. */
544
+ async function uploadSkillBundle(baseUrl, token, path, dir) {
545
+ const form = new FormData();
546
+ for (const file of resolveSkillFiles(dir))
547
+ form.append("files[]", new File([file.bytes], file.rel));
548
+ const res = await fetch(`${baseUrl}${path}`, {
549
+ method: "POST",
550
+ headers: {
551
+ Authorization: `Bearer ${token}`,
552
+ "IC-Api-Version": exports.IC_API_VERSION,
553
+ },
554
+ body: form,
555
+ });
556
+ const text = await res.text();
557
+ if (!res.ok) {
558
+ const err = new Error(`IC POST ${path} → ${res.status}: ${text.slice(0, 400)}`);
559
+ err.status = res.status;
560
+ throw err;
561
+ }
562
+ return text ? JSON.parse(text) : {};
563
+ }
564
+ const skillProvider = {
565
+ async create(i) {
566
+ const skill = await uploadSkillBundle(i.baseUrl, i.token, "/v1/skills", i.path);
567
+ return {
568
+ id: skill.id,
569
+ outs: {
570
+ ...i,
571
+ skillId: skill.id,
572
+ version: skill.default_version,
573
+ contentSig: skillSig(i.path),
574
+ },
575
+ };
576
+ },
577
+ async update(id, _olds, news) {
578
+ // A version is immutable, so an edited bundle is a new one — and the
579
+ // default has to move, or nothing that resolves by default would see it.
580
+ const published = await uploadSkillBundle(news.baseUrl, news.token, `/v1/skills/${id}/versions`, news.path);
581
+ await icRequest(news.baseUrl, news.token, "POST", `/v1/skills/${id}`, {
582
+ default_version: published.version,
583
+ });
584
+ return {
585
+ outs: {
586
+ ...news,
587
+ skillId: id,
588
+ version: published.version,
589
+ contentSig: skillSig(news.path),
590
+ },
591
+ };
592
+ },
593
+ async delete(id, props) {
594
+ await icRequest(props.baseUrl, props.token, "DELETE", `/v1/skills/${id}`);
595
+ },
596
+ async diff(_id, olds, news) {
597
+ // Compare against the sig STORED at the last apply, not a re-resolve of
598
+ // olds: olds.path points at the same folder, whose bytes have already
599
+ // changed, so re-reading it would hide the change (same reasoning as the
600
+ // agent and vector-store diffs).
601
+ const changed = (olds.contentSig ?? "") !== skillSig(news.path);
602
+ return { changes: changed };
603
+ },
604
+ async read(id, props) {
605
+ return read404(() => icRequest(props.baseUrl, props.token, "GET", `/v1/skills/${id}`), (skill) => {
606
+ if (!skill || !skill.id)
607
+ return { id: "" };
608
+ return {
609
+ id,
610
+ props: {
611
+ ...props,
612
+ skillId: skill.id,
613
+ version: skill.default_version,
614
+ },
615
+ };
616
+ });
617
+ },
618
+ };
619
+ class IcSkill extends pulumi.dynamic.Resource {
620
+ constructor(name, args, opts) {
621
+ super(skillProvider, name, { skillId: undefined, version: undefined, contentSig: undefined, ...args }, { ...opts, additionalSecretOutputs: ["token"] });
622
+ }
623
+ }
624
+ exports.IcSkill = IcSkill;
484
625
  // Read each declared file's bytes and hash them (same sha256-hex the API keys
485
626
  // files by) so an edit shows up in the file signature. Module-scope + node
486
627
  // built-ins required inside for Pulumi's closure serialization.
@@ -505,7 +646,9 @@ function resolveVsFiles(files) {
505
646
  // JSON-encodes the body); uses global FormData/File (Node ≥18).
506
647
  async function uploadFile(baseUrl, token, bytes, filename) {
507
648
  const form = new FormData();
508
- form.set("file", new File([new Uint8Array(bytes)], filename, { type: "application/octet-stream" }));
649
+ form.set("file", new File([new Uint8Array(bytes)], filename, {
650
+ type: "application/octet-stream",
651
+ }));
509
652
  form.set("purpose", "assistants");
510
653
  const res = await fetch(`${baseUrl}/v1/files`, {
511
654
  method: "POST",
@@ -583,7 +726,12 @@ const vectorStoreProvider = {
583
726
  const fileState = await syncVsFiles(i, res.id, []);
584
727
  return {
585
728
  id: res.id,
586
- outs: { ...i, vectorStoreId: res.id, fileState, filesSig: vsFilesSig(i.files) },
729
+ outs: {
730
+ ...i,
731
+ vectorStoreId: res.id,
732
+ fileState,
733
+ filesSig: vsFilesSig(i.files),
734
+ },
587
735
  };
588
736
  },
589
737
  async update(id, olds, news) {
@@ -592,7 +740,12 @@ const vectorStoreProvider = {
592
740
  await icRequest(news.baseUrl, news.token, "POST", `/v1/vector_stores/${id}`, body);
593
741
  const fileState = await syncVsFiles(news, id, olds.fileState ?? []);
594
742
  return {
595
- outs: { ...news, vectorStoreId: id, fileState, filesSig: vsFilesSig(news.files) },
743
+ outs: {
744
+ ...news,
745
+ vectorStoreId: id,
746
+ fileState,
747
+ filesSig: vsFilesSig(news.files),
748
+ },
596
749
  };
597
750
  },
598
751
  async delete(id, props) {
@@ -601,7 +754,8 @@ const vectorStoreProvider = {
601
754
  async diff(_id, olds, news) {
602
755
  const scalarChanged = (olds.name ?? undefined) !== (news.name ?? undefined) ||
603
756
  (olds.description ?? undefined) !== (news.description ?? undefined);
604
- const metaChanged = JSON.stringify(olds.metadata ?? null) !== JSON.stringify(news.metadata ?? null);
757
+ const metaChanged = JSON.stringify(olds.metadata ?? null) !==
758
+ JSON.stringify(news.metadata ?? null);
605
759
  // Compare against the sig STORED at the last apply (not a re-resolve of olds):
606
760
  // a file-backed path edits the same bytes, so re-reading olds would hash the
607
761
  // new bytes and hide the change (same reasoning as the agent diff).
@@ -703,7 +857,7 @@ const mcpProvider = makePutResource({
703
857
  class IcMcpServer extends pulumi.dynamic.Resource {
704
858
  constructor(name, args, opts) {
705
859
  super(mcpProvider, name, {
706
- authKind: args.catalog ? undefined : "static",
860
+ authKind: args.catalog ? undefined : "bearer",
707
861
  url: undefined,
708
862
  catalog: undefined,
709
863
  authProvider: undefined,
@@ -891,7 +1045,7 @@ const webhookProvider = {
891
1045
  // Enables `pulumi import` of a webhook a script already created (id is the
892
1046
  // import id). The signing secret is intentionally not recoverable.
893
1047
  async read(id, props) {
894
- const list = await icRequest(props.baseUrl, props.token, "GET", "/v1/tenant/webhooks");
1048
+ const list = await icRequest(props.baseUrl, props.token, "GET", "/v1/tenant/webhooks?limit=200");
895
1049
  const hit = (list.data ?? []).find((w) => w.id === id);
896
1050
  // Absent from the list → gone server-side; empty id lets refresh prune it.
897
1051
  if (!hit)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-cloud/pulumi",
3
- "version": "1.5.0",
3
+ "version": "2.1.0",
4
4
  "description": "Pulumi dynamic resources for the Ingram Cloud /v1 API — projects, project tokens, agents, MCP servers, channels, webhooks, model keys.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://cloud.ingram.tech",
@@ -29,10 +29,10 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@ingram-tech/oxlint-config": "^0.2.2",
32
- "@pulumi/pulumi": "^3.251.0",
33
- "@types/node": "^26.1.1",
34
- "oxfmt": "^0.58.0",
35
- "oxlint": "^1.73.0",
32
+ "@pulumi/pulumi": "^3.255.0",
33
+ "@types/node": "^26.1.2",
34
+ "oxfmt": "0.62.0",
35
+ "oxlint": "^1.77.0",
36
36
  "typescript": "^7.0.2"
37
37
  }
38
38
  }