@intentius/chant-lexicon-aws 0.15.2 → 0.15.3

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.
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "ebb81ef771d7eb7cb8397a82fa80d25a60122ea6fa641b7f8444fb2bd076d134",
4
+ "manifest.json": "8fd217f33f9669b2aebb45d554add781b6abdaf4f32dc8e2166bf4a3fc654485",
5
5
  "meta.json": "511f57814cc8d99a30cbcef2830f21ec374f2919f1d4cffcdc40986b0bf754da",
6
- "types/index.d.ts": "096157407bc4a48fe332cd67cc5b3832d513e63995a5b199b89dc34914ba348d",
6
+ "types/index.d.ts": "5c0d835c66a2e03885da5315b78c9ea7a8e0f36f1e23a3146f17a8b2fef436fd",
7
7
  "rules/hardcoded-region.ts": "5a0eaf7ab391231fe6cd51426ece29539cb4b36f31c8dd060956638fed55722a",
8
8
  "rules/iam-wildcard.ts": "135d7217d278fef50939e605c5da32603ba35674b8c4b5c4d66a06c77903e945",
9
9
  "rules/s3-encryption.ts": "c4f9f8aa8dc382ed98c7b04e820a600590cc75335a8db2e087cbfbfe4003d09e",
@@ -40,5 +40,5 @@
40
40
  "skills/chant-aws.md": "4d65eb160e001f2af42eb7c622a5b498c2fd1f5afbd6c8155615c010fb265c63",
41
41
  "skills/chant-aws-eks.md": "8789255709ff004ad0a875fd5999edcdc66fc6e33d710db058d9f42703bcfdfe"
42
42
  },
43
- "composite": "f57f891aaaf8b25f11c51264e4706ed25405a8324a46071b4c01683e68e833b6"
43
+ "composite": "7a60b028b5154a78374c0fa6fd2601f5d0b1120f44bf6d50242e6a949d710ad0"
44
44
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aws",
3
- "version": "0.15.2",
3
+ "version": "0.15.3",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "AWS",
6
6
  "intrinsics": [
@@ -0,0 +1,69 @@
1
+ export interface AwsApplyArgs {
2
+ /** Path to a built CloudFormation template (JSON/YAML). */
3
+ templatePath: string;
4
+ /** CloudFormation stack name — the deploy boundary. */
5
+ stackName: string;
6
+ /** CFN endpoint override (e.g. Floci `http://localhost:4566`). Default: real CloudFormation. */
7
+ endpoint?: string;
8
+ /** Region (real CFN host + `Version` context). Default: `us-east-1`. */
9
+ region?: string;
10
+ /** Capabilities to acknowledge. Default: `["CAPABILITY_NAMED_IAM"]`. */
11
+ capabilities?: string[];
12
+ /** Stack-settle timeout in ms. Default: `300000`. */
13
+ timeoutMs?: number;
14
+ /** Poll interval in ms. Default: `3000`. */
15
+ intervalMs?: number;
16
+ }
17
+ /** Injectable CFN transport — a form POST returning the raw XML. Mirrors the az/gcp appliers so tests avoid the network. */
18
+ export type AwsHttp = (url: string, form: Record<string, string>, signal?: AbortSignal) => Promise<{
19
+ status: number;
20
+ text: string;
21
+ }>;
22
+ /** The CloudFormation endpoint URL — the override, or the real regional host. */
23
+ export declare function cfnUrl(endpoint?: string, region?: string): string;
24
+ /** A CFN Query-protocol form body: `Action` + `Version` + params. */
25
+ export declare function cfnForm(action: string, params: Record<string, string>): Record<string, string>;
26
+ /** Capabilities as the CFN `Capabilities.member.N` list params. */
27
+ export declare function capabilityParams(capabilities: string[]): Record<string, string>;
28
+ /** First `<tag>…</tag>` text in a CFN XML response. */
29
+ export declare function xmlField(xml: string, tag: string): string | undefined;
30
+ export declare const stackStatus: (xml: string) => string | undefined;
31
+ export declare const stackId: (xml: string) => string | undefined;
32
+ export declare const cfnErrorMessage: (xml: string) => string | undefined;
33
+ /** A DescribeStacks error for an absent stack (drives create-vs-update). */
34
+ export declare const isStackMissing: (xml: string) => boolean;
35
+ /** The real-AWS UpdateStack no-op error (Floci returns 200 instead). */
36
+ export declare const isNoUpdates: (xml: string) => boolean;
37
+ export declare const isSuccessStatus: (status: string) => boolean;
38
+ export declare const isFailureStatus: (status: string) => boolean;
39
+ /** A settled stack state — success, failure, or deleted. Transient `*_IN_PROGRESS` states aren't. */
40
+ export declare const isTerminalStatus: (status: string) => boolean;
41
+ /** Poll DescribeStacks until the stack reaches a terminal state; returns that status. */
42
+ export declare function waitForStackSettled(url: string, stackName: string, http: AwsHttp, opts: {
43
+ timeoutMs: number;
44
+ intervalMs: number;
45
+ }, signal?: AbortSignal): Promise<string>;
46
+ /**
47
+ * The native AWS applier — deploy a built CloudFormation template by calling the
48
+ * CloudFormation API directly (create-or-update + poll to a settled stack),
49
+ * targeting a local Floci emulator or real AWS by endpoint override. The direct
50
+ * twin of `azApply`/`gcpApply`: it speaks the CloudFormation Query API over HTTP
51
+ * rather than shelling `aws cloudformation deploy` (that CLI path is still
52
+ * available via `nativeApply({ target: "cloudformation" })`). The stack is the
53
+ * ownership boundary, so deletes ride CloudFormation itself — no separate prune.
54
+ * `http` is injectable for tests.
55
+ */
56
+ export declare function awsApply(args: AwsApplyArgs, signal?: AbortSignal, http?: AwsHttp): Promise<{
57
+ stackName: string;
58
+ status: string;
59
+ action: "created" | "updated" | "unchanged";
60
+ }>;
61
+ /**
62
+ * The inverse of {@link awsApply} — DeleteStack, then poll until the stack is
63
+ * gone. Idempotent: an already-absent stack is a no-op. `http` is injectable.
64
+ */
65
+ export declare function awsDelete(args: AwsApplyArgs, signal?: AbortSignal, http?: AwsHttp): Promise<{
66
+ stackName: string;
67
+ deleted: boolean;
68
+ }>;
69
+ //# sourceMappingURL=aws-apply.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aws-apply.d.ts","sourceRoot":"","sources":["../../../src/op/activities/aws-apply.ts"],"names":[],"mappings":"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"}
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * AWS Op activities — resolved by the core activity registry when a project's
3
3
  * `chant.config.ts` lists the `aws` lexicon. Contributes the local Floci AWS
4
- * emulator lifecycle (`flociUp`/`flociDown`).
4
+ * emulator lifecycle (`flociUp`/`flociDown`) and the native CloudFormation
5
+ * applier (`awsApply`), which calls the CloudFormation API directly rather than
6
+ * shelling `aws` — the direct twin of `azApply`/`gcpApply`.
5
7
  */
6
8
  export { flociUp, flociDown, flociRunCommand, flociRmCommand, flociExistsCommand, flociHealthUrl, flociEnv, isFlociReady, } from "./floci.js";
7
9
  export type { FlociUpArgs, FlociDownArgs } from "./floci.js";
10
+ export { awsApply, awsDelete, waitForStackSettled, cfnUrl, cfnForm, capabilityParams, xmlField, stackStatus, stackId, cfnErrorMessage, isStackMissing, isNoUpdates, isSuccessStatus, isFailureStatus, isTerminalStatus, } from "./aws-apply.js";
11
+ export type { AwsApplyArgs, AwsHttp } from "./aws-apply.js";
8
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,OAAO,EACP,SAAS,EACT,eAAe,EACf,cAAc,EACd,kBAAkB,EAClB,cAAc,EACd,QAAQ,EACR,YAAY,GACb,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EACL,OAAO,EACP,SAAS,EACT,eAAe,EACf,cAAc,EACd,kBAAkB,EAClB,cAAc,EACd,QAAQ,EACR,YAAY,GACb,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAE1D,OAAO,EACL,QAAQ,EACR,SAAS,EACT,mBAAmB,EACnB,MAAM,EACN,OAAO,EACP,gBAAgB,EAChB,QAAQ,EACR,WAAW,EACX,OAAO,EACP,eAAe,EACf,cAAc,EACd,WAAW,EACX,eAAe,EACf,eAAe,EACf,gBAAgB,GACjB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC"}
@@ -39202,6 +39202,7 @@ export declare class AdvancedSecurityAdditionalFlows {
39202
39202
 
39203
39203
  export declare class AfterContactWorkConfig {
39204
39204
  constructor(props: {
39205
+ AfterContactWorkMode?: ConnectUser_AfterContactWorkMode;
39205
39206
  AfterContactWorkTimeLimit?: number;
39206
39207
  });
39207
39208
  }
@@ -55399,6 +55400,7 @@ export declare class ConnectTestCase_Tag {
55399
55400
 
55400
55401
  export declare class ConnectUser_AfterContactWorkConfig {
55401
55402
  constructor(props: {
55403
+ AfterContactWorkMode?: ConnectUser_AfterContactWorkMode;
55402
55404
  AfterContactWorkTimeLimit?: number;
55403
55405
  });
55404
55406
  }
@@ -126973,6 +126975,8 @@ export type ConnectorProfile_OAuth2GrantType =
126973
126975
 
126974
126976
  export type ConnectorV2_AuthStatus = "ACTIVE" | "FAILED";
126975
126977
 
126978
+ export type ConnectUser_AfterContactWorkMode = "OFF" | "ON" | "ON_DEMAND";
126979
+
126976
126980
  export type ConnectUser_Channel = "CHAT" | "EMAIL" | "TASK" | "VOICE";
126977
126981
 
126978
126982
  export type ConnectUser_PhoneType = "DESK_PHONE" | "SOFT_PHONE";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-aws",
3
- "version": "0.15.2",
3
+ "version": "0.15.3",
4
4
  "description": "AWS CloudFormation lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -80,6 +80,6 @@
80
80
  "typescript": "^5.9.3"
81
81
  },
82
82
  "peerDependencies": {
83
- "@intentius/chant": "^0.15.2"
83
+ "@intentius/chant": "^0.15.3"
84
84
  }
85
85
  }
@@ -39202,6 +39202,7 @@ export declare class AdvancedSecurityAdditionalFlows {
39202
39202
 
39203
39203
  export declare class AfterContactWorkConfig {
39204
39204
  constructor(props: {
39205
+ AfterContactWorkMode?: ConnectUser_AfterContactWorkMode;
39205
39206
  AfterContactWorkTimeLimit?: number;
39206
39207
  });
39207
39208
  }
@@ -55399,6 +55400,7 @@ export declare class ConnectTestCase_Tag {
55399
55400
 
55400
55401
  export declare class ConnectUser_AfterContactWorkConfig {
55401
55402
  constructor(props: {
55403
+ AfterContactWorkMode?: ConnectUser_AfterContactWorkMode;
55402
55404
  AfterContactWorkTimeLimit?: number;
55403
55405
  });
55404
55406
  }
@@ -126973,6 +126975,8 @@ export type ConnectorProfile_OAuth2GrantType =
126973
126975
 
126974
126976
  export type ConnectorV2_AuthStatus = "ACTIVE" | "FAILED";
126975
126977
 
126978
+ export type ConnectUser_AfterContactWorkMode = "OFF" | "ON" | "ON_DEMAND";
126979
+
126976
126980
  export type ConnectUser_Channel = "CHAT" | "EMAIL" | "TASK" | "VOICE";
126977
126981
 
126978
126982
  export type ConnectUser_PhoneType = "DESK_PHONE" | "SOFT_PHONE";
@@ -0,0 +1,143 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { writeFileSync, unlinkSync } from "node:fs";
3
+ import {
4
+ awsApply,
5
+ awsDelete,
6
+ cfnUrl,
7
+ cfnForm,
8
+ capabilityParams,
9
+ xmlField,
10
+ stackStatus,
11
+ stackId,
12
+ isStackMissing,
13
+ isNoUpdates,
14
+ isSuccessStatus,
15
+ isFailureStatus,
16
+ isTerminalStatus,
17
+ type AwsHttp,
18
+ } from "./aws-apply";
19
+
20
+ const CREATE_OK = "<CreateStackResponse><CreateStackResult><StackId>arn:stack/s</StackId></CreateStackResult></CreateStackResponse>";
21
+ const UPDATE_OK = "<UpdateStackResponse><UpdateStackResult><StackId>arn:stack/s</StackId></UpdateStackResult></UpdateStackResponse>";
22
+ const MISSING = "<ErrorResponse><Error><Code>ValidationError</Code><Message>Stack with id s does not exist</Message></Error></ErrorResponse>";
23
+ const NO_UPDATES = "<ErrorResponse><Error><Message>No updates are to be performed.</Message></Error></ErrorResponse>";
24
+ const describe_ = (status: string) => `<DescribeStacksResponse><Stacks><member><StackId>arn:stack/s</StackId><StackStatus>${status}</StackStatus></member></Stacks></DescribeStacksResponse>`;
25
+
26
+ describe("CFN pure helpers (#awsApply)", () => {
27
+ test("cfnUrl: endpoint override vs real regional host", () => {
28
+ expect(cfnUrl("http://localhost:4566")).toBe("http://localhost:4566/");
29
+ expect(cfnUrl(undefined, "eu-west-1")).toBe("https://cloudformation.eu-west-1.amazonaws.com/");
30
+ });
31
+
32
+ test("cfnForm stamps Action + Version", () => {
33
+ expect(cfnForm("CreateStack", { StackName: "s" })).toEqual({ Action: "CreateStack", Version: "2010-05-15", StackName: "s" });
34
+ });
35
+
36
+ test("capabilityParams → Capabilities.member.N", () => {
37
+ expect(capabilityParams(["CAPABILITY_NAMED_IAM", "CAPABILITY_IAM"])).toEqual({
38
+ "Capabilities.member.1": "CAPABILITY_NAMED_IAM",
39
+ "Capabilities.member.2": "CAPABILITY_IAM",
40
+ });
41
+ });
42
+
43
+ test("xml parsing + status classification", () => {
44
+ expect(xmlField(describe_("CREATE_COMPLETE"), "StackStatus")).toBe("CREATE_COMPLETE");
45
+ expect(stackStatus(describe_("UPDATE_COMPLETE"))).toBe("UPDATE_COMPLETE");
46
+ expect(stackId(CREATE_OK)).toBe("arn:stack/s");
47
+ expect(isStackMissing(MISSING)).toBe(true);
48
+ expect(isNoUpdates(NO_UPDATES)).toBe(true);
49
+ expect(isSuccessStatus("CREATE_COMPLETE")).toBe(true);
50
+ expect(isSuccessStatus("UPDATE_COMPLETE_CLEANUP_IN_PROGRESS")).toBe(false); // transient, not settled
51
+ expect(isFailureStatus("ROLLBACK_COMPLETE")).toBe(true);
52
+ expect(isFailureStatus("CREATE_FAILED")).toBe(true);
53
+ expect(isTerminalStatus("CREATE_IN_PROGRESS")).toBe(false);
54
+ expect(isTerminalStatus("DELETE_COMPLETE")).toBe(true);
55
+ });
56
+ });
57
+
58
+ function tmpl(): string {
59
+ const p = `/tmp/chant-cfn-${process.pid}-${Math.round(performance.now())}.json`;
60
+ writeFileSync(p, JSON.stringify({ Resources: { B: { Type: "AWS::S3::Bucket" } } }));
61
+ return p;
62
+ }
63
+
64
+ describe("awsApply flow (#awsApply)", () => {
65
+ test("create path: absent stack → CreateStack → poll to CREATE_COMPLETE", async () => {
66
+ const calls: string[] = [];
67
+ let described = 0;
68
+ const http: AwsHttp = async (_url, form) => {
69
+ calls.push(form.Action);
70
+ if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("CREATE_COMPLETE") };
71
+ return { status: 200, text: CREATE_OK };
72
+ };
73
+ const p = tmpl();
74
+ const res = await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
75
+ unlinkSync(p);
76
+ expect(res).toEqual({ stackName: "s", status: "CREATE_COMPLETE", action: "created" });
77
+ expect(calls).toEqual(["DescribeStacks", "CreateStack", "DescribeStacks"]);
78
+ });
79
+
80
+ test("update path: existing stack → UpdateStack → UPDATE_COMPLETE", async () => {
81
+ let described = 0;
82
+ const http: AwsHttp = async (_url, form) => {
83
+ if (form.Action === "DescribeStacks") return { status: 200, text: describe_(described++ === 0 ? "CREATE_COMPLETE" : "UPDATE_COMPLETE") };
84
+ return { status: 200, text: UPDATE_OK };
85
+ };
86
+ const p = tmpl();
87
+ const res = await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
88
+ unlinkSync(p);
89
+ expect(res.action).toBe("updated");
90
+ expect(res.status).toBe("UPDATE_COMPLETE");
91
+ });
92
+
93
+ test("no-op update (real-AWS error) → unchanged, no poll", async () => {
94
+ const http: AwsHttp = async (_url, form) => {
95
+ if (form.Action === "DescribeStacks") return { status: 200, text: describe_("CREATE_COMPLETE") };
96
+ return { status: 400, text: NO_UPDATES }; // UpdateStack
97
+ };
98
+ const p = tmpl();
99
+ const res = await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x" }, undefined, http);
100
+ unlinkSync(p);
101
+ expect(res).toEqual({ stackName: "s", status: "UPDATE_COMPLETE", action: "unchanged" });
102
+ });
103
+
104
+ test("throws when the stack settles to a failure state", async () => {
105
+ let described = 0;
106
+ const http: AwsHttp = async (_url, form) => {
107
+ if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("ROLLBACK_COMPLETE") };
108
+ return { status: 200, text: CREATE_OK };
109
+ };
110
+ const p = tmpl();
111
+ await expect(
112
+ awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http),
113
+ ).rejects.toThrow(/created → ROLLBACK_COMPLETE/);
114
+ unlinkSync(p);
115
+ });
116
+
117
+ test("surfaces a CreateStack API error", async () => {
118
+ const http: AwsHttp = async (_url, form) =>
119
+ form.Action === "DescribeStacks" ? { status: 400, text: MISSING } : { status: 400, text: "<Error><Message>bad template</Message></Error>" };
120
+ const p = tmpl();
121
+ await expect(
122
+ awsApply({ templatePath: p, stackName: "s", endpoint: "http://x" }, undefined, http),
123
+ ).rejects.toThrow(/CreateStack failed \(400\): bad template/);
124
+ unlinkSync(p);
125
+ });
126
+ });
127
+
128
+ describe("awsDelete (#awsApply)", () => {
129
+ test("DeleteStack then polls until the stack is gone", async () => {
130
+ const calls: string[] = [];
131
+ let described = 0;
132
+ const http: AwsHttp = async (_url, form) => {
133
+ calls.push(form.Action);
134
+ if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 200, text: describe_("DELETE_IN_PROGRESS") } : { status: 400, text: MISSING };
135
+ return { status: 200, text: "<DeleteStackResponse/>" };
136
+ };
137
+ const p = tmpl();
138
+ const res = await awsDelete({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
139
+ unlinkSync(p);
140
+ expect(res).toEqual({ stackName: "s", deleted: true });
141
+ expect(calls[0]).toBe("DeleteStack");
142
+ });
143
+ });
@@ -0,0 +1,187 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { safeHeartbeat, sleep } from "@intentius/chant/op";
3
+
4
+ const DEFAULT_REGION = "us-east-1";
5
+ const CFN_API_VERSION = "2010-05-15";
6
+ const DEFAULT_CAPABILITIES = ["CAPABILITY_NAMED_IAM"];
7
+
8
+ export interface AwsApplyArgs {
9
+ /** Path to a built CloudFormation template (JSON/YAML). */
10
+ templatePath: string;
11
+ /** CloudFormation stack name — the deploy boundary. */
12
+ stackName: string;
13
+ /** CFN endpoint override (e.g. Floci `http://localhost:4566`). Default: real CloudFormation. */
14
+ endpoint?: string;
15
+ /** Region (real CFN host + `Version` context). Default: `us-east-1`. */
16
+ region?: string;
17
+ /** Capabilities to acknowledge. Default: `["CAPABILITY_NAMED_IAM"]`. */
18
+ capabilities?: string[];
19
+ /** Stack-settle timeout in ms. Default: `300000`. */
20
+ timeoutMs?: number;
21
+ /** Poll interval in ms. Default: `3000`. */
22
+ intervalMs?: number;
23
+ }
24
+
25
+ /** Injectable CFN transport — a form POST returning the raw XML. Mirrors the az/gcp appliers so tests avoid the network. */
26
+ export type AwsHttp = (url: string, form: Record<string, string>, signal?: AbortSignal) => Promise<{ status: number; text: string }>;
27
+
28
+ const defaultHttp: AwsHttp = async (url, form, signal) => {
29
+ const res = await fetch(url, {
30
+ method: "POST",
31
+ headers: { "content-type": "application/x-www-form-urlencoded" },
32
+ body: new URLSearchParams(form).toString(),
33
+ signal,
34
+ });
35
+ return { status: res.status, text: await res.text() };
36
+ };
37
+
38
+ // ── Pure helpers (CFN Query protocol) ─────────────────────────────────────────
39
+
40
+ /** The CloudFormation endpoint URL — the override, or the real regional host. */
41
+ export function cfnUrl(endpoint?: string, region = DEFAULT_REGION): string {
42
+ return `${(endpoint ?? `https://cloudformation.${region}.amazonaws.com`).replace(/\/$/, "")}/`;
43
+ }
44
+
45
+ /** A CFN Query-protocol form body: `Action` + `Version` + params. */
46
+ export function cfnForm(action: string, params: Record<string, string>): Record<string, string> {
47
+ return { Action: action, Version: CFN_API_VERSION, ...params };
48
+ }
49
+
50
+ /** Capabilities as the CFN `Capabilities.member.N` list params. */
51
+ export function capabilityParams(capabilities: string[]): Record<string, string> {
52
+ const out: Record<string, string> = {};
53
+ capabilities.forEach((c, i) => (out[`Capabilities.member.${i + 1}`] = c));
54
+ return out;
55
+ }
56
+
57
+ /** First `<tag>…</tag>` text in a CFN XML response. */
58
+ export function xmlField(xml: string, tag: string): string | undefined {
59
+ return xml.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];
60
+ }
61
+
62
+ export const stackStatus = (xml: string): string | undefined => xmlField(xml, "StackStatus");
63
+ export const stackId = (xml: string): string | undefined => xmlField(xml, "StackId");
64
+ export const cfnErrorMessage = (xml: string): string | undefined => xmlField(xml, "Message");
65
+
66
+ /** A DescribeStacks error for an absent stack (drives create-vs-update). */
67
+ export const isStackMissing = (xml: string): boolean => /does not exist/i.test(xml);
68
+ /** The real-AWS UpdateStack no-op error (Floci returns 200 instead). */
69
+ export const isNoUpdates = (xml: string): boolean => /No updates are to be performed/i.test(xml);
70
+
71
+ const SUCCESS_STATUS = new Set(["CREATE_COMPLETE", "UPDATE_COMPLETE", "IMPORT_COMPLETE"]);
72
+ export const isSuccessStatus = (status: string): boolean => SUCCESS_STATUS.has(status);
73
+ export const isFailureStatus = (status: string): boolean => /FAILED|ROLLBACK/.test(status);
74
+ /** A settled stack state — success, failure, or deleted. Transient `*_IN_PROGRESS` states aren't. */
75
+ export const isTerminalStatus = (status: string): boolean =>
76
+ isSuccessStatus(status) || isFailureStatus(status) || status === "DELETE_COMPLETE";
77
+
78
+ // ── Poll ──────────────────────────────────────────────────────────────────────
79
+
80
+ /** Poll DescribeStacks until the stack reaches a terminal state; returns that status. */
81
+ export async function waitForStackSettled(
82
+ url: string,
83
+ stackName: string,
84
+ http: AwsHttp,
85
+ opts: { timeoutMs: number; intervalMs: number },
86
+ signal?: AbortSignal,
87
+ ): Promise<string> {
88
+ const deadline = Date.now() + opts.timeoutMs;
89
+ while (Date.now() < deadline) {
90
+ if (signal?.aborted) throw new Error("awsApply aborted");
91
+ safeHeartbeat({ step: "awsApply", stack: stackName });
92
+ const res = await http(url, cfnForm("DescribeStacks", { StackName: stackName }), signal);
93
+ const status = stackStatus(res.text);
94
+ if (status && isTerminalStatus(status)) return status;
95
+ await sleep(opts.intervalMs, signal);
96
+ }
97
+ throw new Error(`CloudFormation stack ${stackName} did not settle within ${opts.timeoutMs}ms`);
98
+ }
99
+
100
+ // ── Apply / delete ──────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * The native AWS applier — deploy a built CloudFormation template by calling the
104
+ * CloudFormation API directly (create-or-update + poll to a settled stack),
105
+ * targeting a local Floci emulator or real AWS by endpoint override. The direct
106
+ * twin of `azApply`/`gcpApply`: it speaks the CloudFormation Query API over HTTP
107
+ * rather than shelling `aws cloudformation deploy` (that CLI path is still
108
+ * available via `nativeApply({ target: "cloudformation" })`). The stack is the
109
+ * ownership boundary, so deletes ride CloudFormation itself — no separate prune.
110
+ * `http` is injectable for tests.
111
+ */
112
+ export async function awsApply(
113
+ args: AwsApplyArgs,
114
+ signal?: AbortSignal,
115
+ http: AwsHttp = defaultHttp,
116
+ ): Promise<{ stackName: string; status: string; action: "created" | "updated" | "unchanged" }> {
117
+ const url = cfnUrl(args.endpoint, args.region);
118
+ const capabilities = args.capabilities ?? DEFAULT_CAPABILITIES;
119
+ const templateBody = readFileSync(args.templatePath, "utf8");
120
+ const timeoutMs = args.timeoutMs ?? 300_000;
121
+ const intervalMs = args.intervalMs ?? 3_000;
122
+
123
+ const desc = await http(url, cfnForm("DescribeStacks", { StackName: args.stackName }), signal);
124
+ if (desc.status >= 300 && !isStackMissing(desc.text)) {
125
+ throw new Error(`CloudFormation DescribeStacks failed (${desc.status}): ${cfnErrorMessage(desc.text) ?? desc.text}`);
126
+ }
127
+ const exists = desc.status < 300;
128
+
129
+ const params = { StackName: args.stackName, TemplateBody: templateBody, ...capabilityParams(capabilities) };
130
+ let action: "created" | "updated";
131
+ if (!exists) {
132
+ const res = await http(url, cfnForm("CreateStack", params), signal);
133
+ if (res.status >= 300) {
134
+ throw new Error(`CloudFormation CreateStack failed (${res.status}): ${cfnErrorMessage(res.text) ?? res.text}`);
135
+ }
136
+ action = "created";
137
+ } else {
138
+ const res = await http(url, cfnForm("UpdateStack", params), signal);
139
+ if (res.status >= 300) {
140
+ if (isNoUpdates(res.text)) {
141
+ console.log(`unchanged: ${args.stackName} (no updates)`);
142
+ return { stackName: args.stackName, status: "UPDATE_COMPLETE", action: "unchanged" };
143
+ }
144
+ throw new Error(`CloudFormation UpdateStack failed (${res.status}): ${cfnErrorMessage(res.text) ?? res.text}`);
145
+ }
146
+ action = "updated";
147
+ }
148
+
149
+ const status = await waitForStackSettled(url, args.stackName, http, { timeoutMs, intervalMs }, signal);
150
+ if (isFailureStatus(status)) {
151
+ throw new Error(`CloudFormation stack ${args.stackName} ${action} → ${status}`);
152
+ }
153
+ console.log(`${action}: ${args.stackName} (${status}) [${url}]`);
154
+ return { stackName: args.stackName, status, action };
155
+ }
156
+
157
+ /**
158
+ * The inverse of {@link awsApply} — DeleteStack, then poll until the stack is
159
+ * gone. Idempotent: an already-absent stack is a no-op. `http` is injectable.
160
+ */
161
+ export async function awsDelete(
162
+ args: AwsApplyArgs,
163
+ signal?: AbortSignal,
164
+ http: AwsHttp = defaultHttp,
165
+ ): Promise<{ stackName: string; deleted: boolean }> {
166
+ const url = cfnUrl(args.endpoint, args.region);
167
+ const timeoutMs = args.timeoutMs ?? 300_000;
168
+ const intervalMs = args.intervalMs ?? 3_000;
169
+
170
+ const res = await http(url, cfnForm("DeleteStack", { StackName: args.stackName }), signal);
171
+ if (res.status >= 300 && !isStackMissing(res.text)) {
172
+ throw new Error(`CloudFormation DeleteStack failed (${res.status}): ${cfnErrorMessage(res.text) ?? res.text}`);
173
+ }
174
+
175
+ const deadline = Date.now() + timeoutMs;
176
+ while (Date.now() < deadline) {
177
+ if (signal?.aborted) throw new Error("awsDelete aborted");
178
+ safeHeartbeat({ step: "awsDelete", stack: args.stackName });
179
+ const d = await http(url, cfnForm("DescribeStacks", { StackName: args.stackName }), signal);
180
+ if (d.status >= 300 && isStackMissing(d.text)) return { stackName: args.stackName, deleted: true };
181
+ const status = stackStatus(d.text);
182
+ if (status === "DELETE_COMPLETE") return { stackName: args.stackName, deleted: true };
183
+ if (status === "DELETE_FAILED") throw new Error(`CloudFormation stack ${args.stackName} delete → DELETE_FAILED`);
184
+ await sleep(intervalMs, signal);
185
+ }
186
+ throw new Error(`CloudFormation stack ${args.stackName} delete did not complete within ${timeoutMs}ms`);
187
+ }
@@ -115,8 +115,8 @@ export async function flociUp(args: FlociUpArgs, signal?: AbortSignal): Promise<
115
115
  if (signal?.aborted) throw new Error("flociUp aborted");
116
116
  safeHeartbeat({ step: "flociUp", container: name });
117
117
  try {
118
- const { stdout } = await execAsync(`curl -fs ${url}`, { signal });
119
- if (isFlociReady(stdout, service)) {
118
+ const res = await fetch(url, { signal });
119
+ if (res.ok && isFlociReady(await res.text(), service)) {
120
120
  ready = true;
121
121
  break;
122
122
  }
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * AWS Op activities — resolved by the core activity registry when a project's
3
3
  * `chant.config.ts` lists the `aws` lexicon. Contributes the local Floci AWS
4
- * emulator lifecycle (`flociUp`/`flociDown`).
4
+ * emulator lifecycle (`flociUp`/`flociDown`) and the native CloudFormation
5
+ * applier (`awsApply`), which calls the CloudFormation API directly rather than
6
+ * shelling `aws` — the direct twin of `azApply`/`gcpApply`.
5
7
  */
6
8
  export {
7
9
  flociUp,
@@ -14,3 +16,22 @@ export {
14
16
  isFlociReady,
15
17
  } from "./floci";
16
18
  export type { FlociUpArgs, FlociDownArgs } from "./floci";
19
+
20
+ export {
21
+ awsApply,
22
+ awsDelete,
23
+ waitForStackSettled,
24
+ cfnUrl,
25
+ cfnForm,
26
+ capabilityParams,
27
+ xmlField,
28
+ stackStatus,
29
+ stackId,
30
+ cfnErrorMessage,
31
+ isStackMissing,
32
+ isNoUpdates,
33
+ isSuccessStatus,
34
+ isFailureStatus,
35
+ isTerminalStatus,
36
+ } from "./aws-apply";
37
+ export type { AwsApplyArgs, AwsHttp } from "./aws-apply";