aemdm 0.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/LICENSE ADDED
@@ -0,0 +1,176 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
package/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # `aemdm`
2
+
3
+ `aemdm` is a small TypeScript CLI for Adobe Dynamic Media with OpenAPI.
4
+
5
+ It is designed as a practical operator tool for both humans and LLM-driven workflows:
6
+
7
+ - Build Dynamic Media delivery URLs from a known asset ID.
8
+ - Fetch asset metadata JSON.
9
+ - Download transformed or original binaries.
10
+ - Search activated assets with simple exact-match metadata filters.
11
+ - Emit an LLM-oriented usage guide with `--skill`.
12
+ - Persist a default bucket in a local profile config so repeated commands stay short.
13
+
14
+ ## Features
15
+
16
+ - Build asset delivery URLs by asset ID.
17
+ - Apply delivery modifiers such as `format`, `size`, `width`, `height`, `quality`, and `max-quality`.
18
+ - Fetch asset metadata.
19
+ - Download transformed or original binaries.
20
+ - Search activated assets with a simple metadata filter DSL.
21
+ - Pass through raw search JSON when you need the full API request body.
22
+ - Emit asset IDs in pipe-friendly forms for follow-up CLI calls.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ npm install
28
+ ```
29
+
30
+ Install the CLI globally from npm:
31
+
32
+ ```bash
33
+ npm install -g aemdm
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ npm run build
40
+ node dist/cli.js asset get urn:aaid:aem:1234 --bucket delivery-p123-e456.adobeaemcloud.com
41
+ ```
42
+
43
+ ### URL by asset ID
44
+
45
+ ```bash
46
+ node dist/cli.js asset get urn:aaid:aem:1234 \
47
+ --bucket delivery-p123-e456.adobeaemcloud.com \
48
+ --format webp \
49
+ --size 1200x800 \
50
+ --quality 75
51
+ ```
52
+
53
+ ### Metadata
54
+
55
+ ```bash
56
+ node dist/cli.js asset get urn:aaid:aem:1234 --metadata
57
+ ```
58
+
59
+ If an IMS token is available, `--metadata` returns the full metadata document from the metadata endpoint. Without authentication, it returns a smaller public JSON object based on the asset response headers:
60
+
61
+ ```bash
62
+ node dist/cli.js asset get urn:aaid:aem:1234 --metadata --ims-token "$AEMDM_IMS_TOKEN"
63
+ ```
64
+
65
+ ### Original binary
66
+
67
+ ```bash
68
+ node dist/cli.js asset get urn:aaid:aem:1234 \
69
+ --original \
70
+ --binary \
71
+ --output ./asset.bin
72
+ ```
73
+
74
+ Public assets can also be downloaded without passing a token:
75
+
76
+ ```bash
77
+ node dist/cli.js asset get urn:aaid:aem:1234 --binary --output ./asset.bin
78
+ ```
79
+
80
+ ### Search
81
+
82
+ ```bash
83
+ node dist/cli.js search \
84
+ --where x:y=z \
85
+ --where repositoryMetadata.dc:format=image/jpeg,image/png
86
+ ```
87
+
88
+ ### Search Output Modes
89
+
90
+ The default `search` output is a compact table for humans. For automation and piping, use one of these:
91
+
92
+ ```bash
93
+ node dist/cli.js search --text "hero" --first-id
94
+ node dist/cli.js search --text "hero" --ids-only
95
+ node dist/cli.js search --text "hero" --json
96
+ ```
97
+
98
+ These are useful when another command needs an asset ID:
99
+
100
+ ```bash
101
+ aemdm search --where x:y=z --first-id
102
+ aemdm search --where x:y=z --ids-only | xargs -I {} aemdm asset get {}
103
+ ```
104
+
105
+ If you want raw response automation with `jq`:
106
+
107
+ ```bash
108
+ aemdm search --text "hero" --json | jq -r '.hits.results[0].assetId'
109
+ ```
110
+
111
+ ### LLM Skill Output
112
+
113
+ Use `--skill` to print a concise guide that explains what the tool does, which commands to use, and how an LLM should choose between them.
114
+
115
+ ```bash
116
+ node dist/cli.js --skill
117
+ ```
118
+
119
+ Example output areas:
120
+
121
+ - tool purpose
122
+ - core commands
123
+ - asset and search examples
124
+ - metadata filter DSL behavior
125
+ - raw query examples
126
+ - LLM usage guidance for when to use `asset get`, `search`, `--first-url`, `--first-metadata`, and `--first-binary`
127
+
128
+ ### Save a Default Bucket
129
+
130
+ You can save the bucket once and omit `--bucket` from later commands:
131
+
132
+ ```bash
133
+ node dist/cli.js --bucket delivery-p123-e456.adobeaemcloud.com
134
+ ```
135
+
136
+ After that, regular commands can use the saved profile bucket:
137
+
138
+ ```bash
139
+ node dist/cli.js asset get urn:aaid:aem:1234
140
+ node dist/cli.js search --where x:y=z
141
+ ```
142
+
143
+ ### First result helpers
144
+
145
+ ```bash
146
+ node dist/cli.js search --text "hero banner" --first-url --format webp --width 800
147
+ node dist/cli.js search --text "hero banner" --first-metadata
148
+ node dist/cli.js search --text "hero banner" --first-binary --output ./hero.bin
149
+ ```
150
+
151
+ ## Configuration
152
+
153
+ Resolution precedence is:
154
+
155
+ 1. explicit flags
156
+ 2. environment variables
157
+ 3. saved profile config
158
+
159
+ Profile config path:
160
+
161
+ - `$AEMDM_CONFIG_PATH`, if set
162
+ - otherwise `$XDG_CONFIG_HOME/aemdm/config.json`
163
+ - otherwise `$HOME/.config/aemdm/config.json`
164
+
165
+ - `AEMDM_BUCKET`
166
+ - `AEMDM_IMS_TOKEN`
167
+ - `AEMDM_API_KEY` (defaults to `asset_search_service`)
168
+
169
+ ## `--skill` Examples
170
+
171
+ These are especially useful when another tool or agent needs to understand how to call `aemdm`.
172
+
173
+ ```bash
174
+ node dist/cli.js --skill
175
+ ```
176
+
177
+ ```bash
178
+ aemdm --skill
179
+ ```
180
+
181
+ Typical LLM-oriented patterns:
182
+
183
+ ```bash
184
+ aemdm --bucket delivery-p123-e456.adobeaemcloud.com
185
+ aemdm search --where x:y=z --first-id
186
+ aemdm search --text "hero" --ids-only
187
+ aemdm search --text "hero" --json
188
+ aemdm asset get urn:aaid:aem:1234 --format webp --size 1600x900 --quality 80
189
+ aemdm search --where x:y=z --first-url --format jpg --width 1200
190
+ aemdm search --raw-query @./query.json
191
+ ```
192
+
193
+ ## Publishing
194
+
195
+ Before publishing a new release, verify the package contents and publish from the repo root:
196
+
197
+ ```bash
198
+ npm test
199
+ npm run pack:check
200
+ npm publish
201
+ ```
202
+
203
+ `prepack` builds `dist/`, and `prepublishOnly` runs the test suite during `npm publish`.
204
+
205
+ ### Automated npm publish from GitHub
206
+
207
+ The repository includes a GitHub Actions workflow that publishes to npm when you push a version tag like `v0.1.0` or publish a GitHub release for that tag.
208
+
209
+ Required setup:
210
+
211
+ ```bash
212
+ NPM_TOKEN
213
+ ```
214
+
215
+ Add `NPM_TOKEN` as a GitHub repository secret with permission to publish the `aemdm` package on npm.
216
+
217
+ Release flow:
218
+
219
+ ```bash
220
+ git tag v0.1.0
221
+ git push origin v0.1.0
222
+ ```
223
+
224
+ The workflow verifies that the tag matches the `version` field in `package.json`, runs `lint`, `build`, and `test`, and then publishes the package.
225
+
226
+ ## References
227
+
228
+ - [Dynamic Media with OpenAPI spec](https://developer.adobe.com/experience-cloud/experience-manager-apis/api/stable/assets/delivery/)
229
+ - [Delivery APIs](https://experienceleague.adobe.com/en/docs/experience-manager-cloud-service/content/assets/dynamicmedia/dynamic-media-open-apis/deliver-assets-apis)
230
+ - [Search Assets API](https://experienceleague.adobe.com/en/docs/experience-manager-cloud-service/content/assets/dynamicmedia/dynamic-media-open-apis/search-assets-api)
package/dist/cli.js ADDED
@@ -0,0 +1,477 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from "node:url";
3
+ import { Command, CommanderError, Option } from "commander";
4
+ import { z } from "zod";
5
+ import { CliError, request, requestJson, resolveBucket, resolveOptionalImsToken, resolveSearchAuth, } from "./lib/client.js";
6
+ import { readProfileConfig, writeProfileConfig, } from "./lib/config.js";
7
+ import { buildAssetUrl, buildMetadataUrl, deliveryFormatSchema, resolveDimensions, } from "./lib/delivery.js";
8
+ import { buildSearchRequest, getAssetIdFromHit, loadRawQuery, } from "./lib/search.js";
9
+ import { formatSearchResults, writeBinaryOutput, writeJson, writeLine, } from "./lib/output.js";
10
+ const numberOption = (flagName) => (value) => {
11
+ const parsed = Number(value);
12
+ if (!Number.isInteger(parsed)) {
13
+ throw new CliError(`${flagName} must be an integer.`);
14
+ }
15
+ return parsed;
16
+ };
17
+ const collectValues = (value, previous = []) => [...previous, value];
18
+ const assetGetSchema = z
19
+ .object({
20
+ bucket: z.string().optional(),
21
+ format: deliveryFormatSchema.optional(),
22
+ size: z.string().optional(),
23
+ width: z.number().int().min(1).optional(),
24
+ height: z.number().int().min(1).optional(),
25
+ quality: z.number().int().min(1).max(100).optional(),
26
+ maxQuality: z.number().int().min(1).max(100).optional(),
27
+ seoName: z.string().trim().min(1).default("asset"),
28
+ original: z.boolean().default(false),
29
+ binary: z.boolean().default(false),
30
+ output: z.string().optional(),
31
+ metadata: z.boolean().default(false),
32
+ imsToken: z.string().optional(),
33
+ })
34
+ .superRefine((value, ctx) => {
35
+ if (value.metadata && value.output) {
36
+ ctx.addIssue({
37
+ code: z.ZodIssueCode.custom,
38
+ message: "--output cannot be used with --metadata.",
39
+ path: ["output"],
40
+ });
41
+ }
42
+ if (value.metadata && value.binary) {
43
+ ctx.addIssue({
44
+ code: z.ZodIssueCode.custom,
45
+ message: "--metadata and --binary cannot be used together.",
46
+ path: ["binary"],
47
+ });
48
+ }
49
+ if (value.metadata && value.original) {
50
+ ctx.addIssue({
51
+ code: z.ZodIssueCode.custom,
52
+ message: "--metadata and --original cannot be used together.",
53
+ path: ["original"],
54
+ });
55
+ }
56
+ if (value.metadata && (value.format || value.size || value.width || value.height || value.quality || value.maxQuality)) {
57
+ ctx.addIssue({
58
+ code: z.ZodIssueCode.custom,
59
+ message: "--metadata cannot be combined with delivery modifier flags.",
60
+ path: ["metadata"],
61
+ });
62
+ }
63
+ if (value.binary && !value.output) {
64
+ ctx.addIssue({
65
+ code: z.ZodIssueCode.custom,
66
+ message: "--output is required when using --binary. Use --output - for stdout.",
67
+ path: ["output"],
68
+ });
69
+ }
70
+ if (value.original && (value.format || value.size || value.width || value.height || value.quality || value.maxQuality)) {
71
+ ctx.addIssue({
72
+ code: z.ZodIssueCode.custom,
73
+ message: "--original cannot be combined with delivery modifier flags.",
74
+ path: ["original"],
75
+ });
76
+ }
77
+ });
78
+ const searchSchema = z
79
+ .object({
80
+ bucket: z.string().optional(),
81
+ imsToken: z.string().optional(),
82
+ apiKey: z.string().optional(),
83
+ text: z.string().optional(),
84
+ where: z.array(z.string()).default([]),
85
+ limit: z.number().int().min(0).max(1000).optional(),
86
+ cursor: z.string().optional(),
87
+ field: z.array(z.string()).default([]),
88
+ sort: z.array(z.string()).default([]),
89
+ rawQuery: z.string().optional(),
90
+ json: z.boolean().default(false),
91
+ idsOnly: z.boolean().default(false),
92
+ firstId: z.boolean().default(false),
93
+ firstUrl: z.boolean().default(false),
94
+ firstMetadata: z.boolean().default(false),
95
+ firstBinary: z.boolean().default(false),
96
+ output: z.string().optional(),
97
+ format: deliveryFormatSchema.optional(),
98
+ size: z.string().optional(),
99
+ width: z.number().int().min(1).optional(),
100
+ height: z.number().int().min(1).optional(),
101
+ quality: z.number().int().min(1).max(100).optional(),
102
+ maxQuality: z.number().int().min(1).max(100).optional(),
103
+ seoName: z.string().trim().min(1).default("asset"),
104
+ original: z.boolean().default(false),
105
+ })
106
+ .superRefine((value, ctx) => {
107
+ const firstModeCount = [value.firstId, value.firstUrl, value.firstMetadata, value.firstBinary]
108
+ .filter(Boolean).length;
109
+ if (firstModeCount > 1) {
110
+ ctx.addIssue({
111
+ code: z.ZodIssueCode.custom,
112
+ message: "Use only one of --first-id, --first-url, --first-metadata, or --first-binary.",
113
+ path: ["firstId"],
114
+ });
115
+ }
116
+ const outputModeCount = [value.json, value.idsOnly, firstModeCount > 0].filter(Boolean).length;
117
+ if (outputModeCount > 1) {
118
+ ctx.addIssue({
119
+ code: z.ZodIssueCode.custom,
120
+ message: "Use only one of --json, --ids-only, or a --first-* option.",
121
+ path: ["json"],
122
+ });
123
+ }
124
+ if (value.firstBinary && !value.output) {
125
+ ctx.addIssue({
126
+ code: z.ZodIssueCode.custom,
127
+ message: "--output is required when using --first-binary. Use --output - for stdout.",
128
+ path: ["output"],
129
+ });
130
+ }
131
+ if (!value.firstBinary && value.output) {
132
+ ctx.addIssue({
133
+ code: z.ZodIssueCode.custom,
134
+ message: "--output is only valid with --first-binary.",
135
+ path: ["output"],
136
+ });
137
+ }
138
+ if (value.rawQuery && (value.text || value.where.length > 0 || value.field.length > 0 || value.sort.length > 0 || value.limit !== undefined || value.cursor)) {
139
+ ctx.addIssue({
140
+ code: z.ZodIssueCode.custom,
141
+ message: "--raw-query cannot be combined with --text, --where, --field, --sort, --limit, or --cursor.",
142
+ path: ["rawQuery"],
143
+ });
144
+ }
145
+ if (value.original && (value.format || value.size || value.width || value.height || value.quality || value.maxQuality)) {
146
+ ctx.addIssue({
147
+ code: z.ZodIssueCode.custom,
148
+ message: "--original cannot be combined with delivery modifier flags.",
149
+ path: ["original"],
150
+ });
151
+ }
152
+ });
153
+ function parseWithSchema(schema, options) {
154
+ const result = schema.safeParse(options);
155
+ if (!result.success) {
156
+ throw new CliError(result.error.issues.map((issue) => issue.message).join("\n"));
157
+ }
158
+ return result.data;
159
+ }
160
+ async function handleAssetGet(assetId, options, runtime) {
161
+ const parsed = parseWithSchema(assetGetSchema, options);
162
+ const profile = await readProfileConfig(runtime.env);
163
+ const baseUrl = resolveBucket(parsed.bucket, runtime.env, profile.bucket);
164
+ const imsToken = resolveOptionalImsToken(parsed.imsToken, runtime.env);
165
+ const dimensions = resolveDimensions(parsed.size, parsed.width, parsed.height);
166
+ if (parsed.metadata) {
167
+ const metadata = imsToken
168
+ ? await requestJson(buildMetadataUrl(baseUrl, assetId), {
169
+ imsToken,
170
+ fetchImpl: runtime.fetchImpl,
171
+ })
172
+ : await fetchBasicAssetMetadata(assetId, baseUrl, runtime);
173
+ writeJson(runtime.stdout, metadata);
174
+ return;
175
+ }
176
+ const url = buildAssetUrl(baseUrl, {
177
+ assetId,
178
+ seoName: parsed.seoName,
179
+ format: parsed.format,
180
+ width: dimensions.width,
181
+ height: dimensions.height,
182
+ quality: parsed.quality,
183
+ maxQuality: parsed.maxQuality,
184
+ original: parsed.original,
185
+ });
186
+ if (!parsed.binary) {
187
+ writeLine(runtime.stdout, url);
188
+ return;
189
+ }
190
+ const response = await request(url, {
191
+ imsToken,
192
+ fetchImpl: runtime.fetchImpl,
193
+ });
194
+ await writeBinaryOutput(response, parsed.output, runtime.stdout);
195
+ }
196
+ async function buildSearchBody(parsed) {
197
+ if (parsed.rawQuery) {
198
+ return loadRawQuery(parsed.rawQuery);
199
+ }
200
+ return buildSearchRequest({
201
+ text: parsed.text,
202
+ where: parsed.where,
203
+ fields: parsed.field,
204
+ sort: parsed.sort,
205
+ limit: parsed.limit,
206
+ cursor: parsed.cursor,
207
+ });
208
+ }
209
+ async function handleSearch(options, runtime) {
210
+ const parsed = parseWithSchema(searchSchema, options);
211
+ const profile = await readProfileConfig(runtime.env);
212
+ const baseUrl = resolveBucket(parsed.bucket, runtime.env, profile.bucket);
213
+ const { imsToken, apiKey } = resolveSearchAuth(parsed.imsToken, parsed.apiKey, runtime.env);
214
+ const searchBody = await buildSearchBody(parsed);
215
+ const searchUrl = `${baseUrl}/search`;
216
+ const response = await requestJson(searchUrl, {
217
+ method: "POST",
218
+ imsToken,
219
+ apiKey,
220
+ jsonBody: searchBody,
221
+ fetchImpl: runtime.fetchImpl,
222
+ });
223
+ if (parsed.json) {
224
+ writeJson(runtime.stdout, response);
225
+ return;
226
+ }
227
+ if (parsed.idsOnly) {
228
+ const hits = response.hits?.results ?? [];
229
+ for (const hit of hits) {
230
+ writeLine(runtime.stdout, getAssetIdFromHit(hit));
231
+ }
232
+ return;
233
+ }
234
+ const firstHit = response.hits?.results?.[0];
235
+ if (parsed.firstId || parsed.firstUrl || parsed.firstMetadata || parsed.firstBinary) {
236
+ if (!firstHit) {
237
+ throw new CliError("No search results found for the requested first-result action.");
238
+ }
239
+ const assetId = getAssetIdFromHit(firstHit);
240
+ if (parsed.firstId) {
241
+ writeLine(runtime.stdout, assetId);
242
+ return;
243
+ }
244
+ if (parsed.firstMetadata) {
245
+ const metadata = await requestJson(buildMetadataUrl(baseUrl, assetId), {
246
+ imsToken,
247
+ fetchImpl: runtime.fetchImpl,
248
+ });
249
+ writeJson(runtime.stdout, metadata);
250
+ return;
251
+ }
252
+ const dimensions = resolveDimensions(parsed.size, parsed.width, parsed.height);
253
+ const assetUrl = buildAssetUrl(baseUrl, {
254
+ assetId,
255
+ seoName: parsed.seoName,
256
+ format: parsed.format,
257
+ width: dimensions.width,
258
+ height: dimensions.height,
259
+ quality: parsed.quality,
260
+ maxQuality: parsed.maxQuality,
261
+ original: parsed.original,
262
+ });
263
+ if (parsed.firstUrl) {
264
+ writeLine(runtime.stdout, assetUrl);
265
+ return;
266
+ }
267
+ const binaryResponse = await request(assetUrl, {
268
+ imsToken,
269
+ fetchImpl: runtime.fetchImpl,
270
+ });
271
+ await writeBinaryOutput(binaryResponse, parsed.output, runtime.stdout);
272
+ return;
273
+ }
274
+ writeLine(runtime.stdout, formatSearchResults(response));
275
+ }
276
+ async function fetchBasicAssetMetadata(assetId, baseUrl, runtime) {
277
+ const url = buildAssetUrl(baseUrl, { assetId });
278
+ let response = await runtime.fetchImpl(url, { method: "HEAD" });
279
+ // Some delivery endpoints may not support HEAD, so fall back to GET and
280
+ // discard the body after reading the headers.
281
+ if (response.status === 405 || response.status === 501) {
282
+ response = await runtime.fetchImpl(url);
283
+ await response.body?.cancel();
284
+ }
285
+ if (!response.ok) {
286
+ throw new CliError(`Unable to fetch public metadata for ${url}: HTTP ${response.status} ${response.statusText}`);
287
+ }
288
+ const contentLengthHeader = response.headers.get("content-length");
289
+ const contentLength = contentLengthHeader === null ? undefined : Number(contentLengthHeader);
290
+ return {
291
+ mode: "basic",
292
+ assetId,
293
+ publicUrl: response.url || url,
294
+ contentType: response.headers.get("content-type") ?? undefined,
295
+ contentDisposition: response.headers.get("content-disposition") ?? undefined,
296
+ etag: response.headers.get("etag") ?? undefined,
297
+ cacheControl: response.headers.get("cache-control") ?? undefined,
298
+ contentLength: Number.isFinite(contentLength) ? contentLength : undefined,
299
+ auth: "public",
300
+ };
301
+ }
302
+ function configureCommonDeliveryOptions(command) {
303
+ return command
304
+ .addOption(new Option("--format <format>", "Output format").choices(deliveryFormatSchema.options))
305
+ .option("--size <WxH>", "Convenience image size, for example 300x200, 300x, or x200")
306
+ .option("--width <px>", "Output width in pixels", numberOption("--width"))
307
+ .option("--height <px>", "Output height in pixels", numberOption("--height"))
308
+ .option("--quality <1-100>", "Output quality", numberOption("--quality"))
309
+ .option("--max-quality <1-100>", "Max dynamic quality", numberOption("--max-quality"))
310
+ .option("--seo-name <name>", "SEO name for transformed/original routes", "asset")
311
+ .option("--original", "Use the original binary route");
312
+ }
313
+ function buildProgram(runtime) {
314
+ const program = new Command();
315
+ program
316
+ .name("aemdm")
317
+ .description("CLI for Adobe Dynamic Media with OpenAPI")
318
+ .version("0.1.0")
319
+ .showHelpAfterError()
320
+ .configureOutput({
321
+ writeOut: (text) => runtime.stdout.write(text),
322
+ writeErr: (text) => runtime.stderr.write(text),
323
+ })
324
+ .exitOverride();
325
+ configureCommonDeliveryOptions(program.command("asset").description("Asset operations"))
326
+ .command("get <assetId>")
327
+ .description("Build a delivery URL, fetch metadata, or download a binary for an asset")
328
+ .option("--bucket <bucket-or-url>", "Bucket host or full bucket URL")
329
+ .option("--binary", "Fetch the binary instead of printing the URL")
330
+ .option("--output <file>", "Output file path for --binary. Use - for stdout")
331
+ .option("--metadata", "Fetch metadata JSON instead of building a URL")
332
+ .option("--ims-token <token>", "IMS bearer token for metadata or binary requests")
333
+ .action((assetId, options) => handleAssetGet(assetId, options, runtime));
334
+ configureCommonDeliveryOptions(program.command("search").description("Search assets and optionally resolve the first result"))
335
+ .option("--bucket <bucket-or-url>", "Bucket host or full bucket URL")
336
+ .option("--ims-token <token>", "IMS bearer token")
337
+ .option("--api-key <key>", "Adobe API key")
338
+ .option("--text <query>", "Full-text search query")
339
+ .option("--where <field=value>", "Metadata filter clause", collectValues, [])
340
+ .option("--limit <n>", "Maximum items to fetch", numberOption("--limit"))
341
+ .option("--cursor <cursor>", "Opaque cursor for pagination")
342
+ .option("--field <path>", "Projected field to include in results", collectValues, [])
343
+ .option("--sort <field[:ASC|DESC]>", "Sort rule", collectValues, [])
344
+ .option("--raw-query <json-or-@file>", "Raw JSON search request or @file path")
345
+ .option("--json", "Print the raw search response as JSON")
346
+ .option("--ids-only", "Print one asset ID per line")
347
+ .option("--first-id", "Print the first matching asset ID")
348
+ .option("--first-url", "Print a delivery URL for the first result")
349
+ .option("--first-metadata", "Fetch metadata for the first result")
350
+ .option("--first-binary", "Download the first result binary")
351
+ .option("--output <file>", "Output file path for --first-binary. Use - for stdout")
352
+ .action((options) => handleSearch(options, runtime));
353
+ return program;
354
+ }
355
+ function renderSkillText() {
356
+ return `aemdm skill
357
+
358
+ Purpose:
359
+ Use aemdm to work with Adobe Dynamic Media with OpenAPI assets from the command line. It can build delivery URLs, fetch metadata, download binaries, and search activated assets.
360
+
361
+ Core commands:
362
+ - aemdm asset get <assetId>
363
+ - aemdm search
364
+
365
+ Important defaults:
366
+ - Bucket comes from --bucket or AEMDM_BUCKET.
367
+ - A standalone call like aemdm --bucket delivery-p123-e456.adobeaemcloud.com saves the default bucket to the local aemdm profile config.
368
+ - Search auth comes from --ims-token/AEMDM_IMS_TOKEN and --api-key/AEMDM_API_KEY.
369
+ - asset get prints a URL by default.
370
+ - asset get --metadata prints full JSON metadata when authenticated, or basic public JSON metadata when no token is supplied.
371
+ - asset get --binary downloads the asset and requires --output.
372
+ - search --first-id prints one asset ID for piping.
373
+ - search --ids-only prints one asset ID per line.
374
+ - search --json prints the raw search response.
375
+
376
+ Asset URL examples:
377
+ - aemdm asset get urn:aaid:aem:1234 --bucket delivery-p123-e456.adobeaemcloud.com
378
+ - aemdm asset get urn:aaid:aem:1234 --format webp --size 1200x800 --quality 75
379
+ - aemdm asset get urn:aaid:aem:1234 --original --binary --output ./asset.bin
380
+ - aemdm asset get urn:aaid:aem:1234 --metadata
381
+ - aemdm asset get urn:aaid:aem:1234 --metadata --ims-token <token>
382
+
383
+ Search examples:
384
+ - aemdm search --text "hero banner"
385
+ - aemdm search --where x:y=z
386
+ - aemdm search --where repositoryMetadata.dc:format=image/jpeg,image/png
387
+ - aemdm search --first-id --where x:y=z
388
+ - aemdm search --ids-only --text "homepage"
389
+ - aemdm search --json --text "homepage"
390
+ - aemdm search --text "homepage" --first-url --format webp --width 800
391
+ - aemdm search --text "homepage" --first-metadata
392
+ - aemdm search --text "homepage" --first-binary --output ./first.bin
393
+
394
+ Filter DSL:
395
+ - --where x:y=z maps to assetMetadata.x:y = ["z"]
396
+ - --where repositoryMetadata.dc:format=image/jpeg,image/png maps to an exact-match term filter with multiple values
397
+
398
+ Raw query escape hatch:
399
+ - aemdm search --raw-query '{"query":[{"match":{"text":"homepage","mode":"FULLTEXT"}}]}'
400
+ - aemdm search --raw-query @./query.json
401
+
402
+ LLM usage guidance:
403
+ - Use asset get when you already know the asset ID.
404
+ - Use search when you need to discover an asset by metadata or text.
405
+ - Prefer --first-id or --ids-only when another CLI call needs asset IDs.
406
+ - Prefer --first-url when the user wants a delivery URL from a search result.
407
+ - Prefer --first-metadata when the user wants the resolved asset metadata after search.
408
+ - Prefer --first-binary with --output when the user wants the downloaded file.
409
+ `;
410
+ }
411
+ function getStandaloneBucketValue(argv) {
412
+ if (argv.length === 1 && argv[0].startsWith("--bucket=")) {
413
+ return argv[0].slice("--bucket=".length);
414
+ }
415
+ if (argv.length === 2 && argv[0] === "--bucket") {
416
+ return argv[1];
417
+ }
418
+ return undefined;
419
+ }
420
+ function toCliError(error) {
421
+ if (error instanceof CliError) {
422
+ return error;
423
+ }
424
+ if (error instanceof CommanderError) {
425
+ if (Number(error.exitCode) === 0 ||
426
+ error.code === "commander.help" ||
427
+ error.code === "commander.helpDisplayed" ||
428
+ error.code === "commander.version" ||
429
+ error.code === "commander.versionDisplayed") {
430
+ return new CliError("", 0);
431
+ }
432
+ return new CliError(error.message, Number(error.exitCode) || 1);
433
+ }
434
+ if (error instanceof Error) {
435
+ return new CliError(error.message);
436
+ }
437
+ return new CliError(String(error));
438
+ }
439
+ export async function runCli(argv, runtimeOverrides = {}) {
440
+ const runtime = {
441
+ env: process.env,
442
+ stdout: process.stdout,
443
+ stderr: process.stderr,
444
+ fetchImpl: fetch,
445
+ ...runtimeOverrides,
446
+ };
447
+ try {
448
+ const standaloneBucket = getStandaloneBucketValue(argv);
449
+ if (standaloneBucket !== undefined) {
450
+ const bucket = resolveBucket(standaloneBucket, runtime.env);
451
+ const configPath = await writeProfileConfig(runtime.env, { bucket });
452
+ writeLine(runtime.stdout, `Saved bucket to profile config: ${bucket}`);
453
+ writeLine(runtime.stdout, `Config file: ${configPath}`);
454
+ return 0;
455
+ }
456
+ if (argv.includes("--skill")) {
457
+ writeLine(runtime.stdout, renderSkillText().trimEnd());
458
+ return 0;
459
+ }
460
+ const program = buildProgram(runtime);
461
+ await program.parseAsync(argv, { from: "user" });
462
+ return 0;
463
+ }
464
+ catch (error) {
465
+ const cliError = toCliError(error);
466
+ if (cliError.message) {
467
+ writeLine(runtime.stderr, cliError.message);
468
+ }
469
+ return cliError.exitCode;
470
+ }
471
+ }
472
+ const executedDirectly = process.argv[1] !== undefined &&
473
+ import.meta.url === pathToFileURL(process.argv[1]).href;
474
+ if (executedDirectly) {
475
+ const exitCode = await runCli(process.argv.slice(2));
476
+ process.exitCode = exitCode;
477
+ }
@@ -0,0 +1,91 @@
1
+ import { z } from "zod";
2
+ export class CliError extends Error {
3
+ exitCode;
4
+ constructor(message, exitCode = 1) {
5
+ super(message);
6
+ this.name = "CliError";
7
+ this.exitCode = exitCode;
8
+ }
9
+ }
10
+ export class HttpError extends CliError {
11
+ status;
12
+ statusText;
13
+ url;
14
+ responseBody;
15
+ constructor(response, url, responseBody) {
16
+ const detail = responseBody ? `\n${responseBody}` : "";
17
+ super(`HTTP ${response.status} ${response.statusText} for ${url}${detail}`, 1);
18
+ this.name = "HttpError";
19
+ this.status = response.status;
20
+ this.statusText = response.statusText;
21
+ this.url = url;
22
+ this.responseBody = responseBody;
23
+ }
24
+ }
25
+ const bucketSchema = z
26
+ .string()
27
+ .trim()
28
+ .min(1, "Bucket value cannot be empty.");
29
+ export function normalizeBucket(input) {
30
+ const raw = bucketSchema.parse(input);
31
+ const url = raw.startsWith("http://") || raw.startsWith("https://")
32
+ ? new URL(raw)
33
+ : new URL(`https://${raw}`);
34
+ return new URL("/adobe/assets", url.origin).toString().replace(/\/$/, "");
35
+ }
36
+ export function resolveBucket(explicitValue, env, profileBucket) {
37
+ const bucket = explicitValue ?? env.AEMDM_BUCKET ?? profileBucket;
38
+ if (!bucket) {
39
+ throw new CliError("Missing bucket. Use --bucket, set AEMDM_BUCKET, or save a profile bucket with `aemdm --bucket <bucket>`.");
40
+ }
41
+ return normalizeBucket(bucket);
42
+ }
43
+ export function resolveOptionalImsToken(explicitValue, env) {
44
+ return explicitValue ?? env.AEMDM_IMS_TOKEN;
45
+ }
46
+ export function resolveSearchAuth(imsToken, apiKey, env) {
47
+ const resolvedImsToken = imsToken ?? env.AEMDM_IMS_TOKEN;
48
+ const resolvedApiKey = apiKey ?? env.AEMDM_API_KEY ?? "asset_search_service";
49
+ if (!resolvedImsToken) {
50
+ throw new CliError("Missing IMS token. Use --ims-token or set AEMDM_IMS_TOKEN.");
51
+ }
52
+ return {
53
+ imsToken: resolvedImsToken,
54
+ apiKey: resolvedApiKey,
55
+ };
56
+ }
57
+ function buildHeaders(options) {
58
+ const headers = new Headers(options.headers);
59
+ if (options.imsToken) {
60
+ headers.set("Authorization", `Bearer ${options.imsToken}`);
61
+ }
62
+ if (options.apiKey) {
63
+ headers.set("X-Api-Key", options.apiKey);
64
+ }
65
+ if (options.jsonBody !== undefined) {
66
+ headers.set("Content-Type", "application/json");
67
+ headers.set("X-Adobe-Accept-Experimental", "1");
68
+ }
69
+ return headers;
70
+ }
71
+ export function joinAssetUrl(baseUrl, path) {
72
+ const normalizedBase = `${baseUrl.replace(/\/+$/, "")}/`;
73
+ return new URL(path.replace(/^\/+/, ""), normalizedBase).toString();
74
+ }
75
+ export async function request(url, options = {}) {
76
+ const fetchImpl = options.fetchImpl ?? fetch;
77
+ const response = await fetchImpl(url, {
78
+ method: options.method ?? (options.jsonBody === undefined ? "GET" : "POST"),
79
+ headers: buildHeaders(options),
80
+ body: options.jsonBody === undefined ? undefined : JSON.stringify(options.jsonBody),
81
+ });
82
+ if (!response.ok) {
83
+ const responseBody = await response.text();
84
+ throw new HttpError(response, url, responseBody);
85
+ }
86
+ return response;
87
+ }
88
+ export async function requestJson(url, options = {}) {
89
+ const response = await request(url, options);
90
+ return response.json();
91
+ }
@@ -0,0 +1,50 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ const profileSchema = z.object({
5
+ bucket: z.string().optional(),
6
+ });
7
+ export function resolveConfigPath(env) {
8
+ if (env.AEMDM_CONFIG_PATH) {
9
+ return env.AEMDM_CONFIG_PATH;
10
+ }
11
+ const home = env.HOME;
12
+ const xdgConfigHome = env.XDG_CONFIG_HOME;
13
+ if (xdgConfigHome) {
14
+ return path.join(xdgConfigHome, "aemdm", "config.json");
15
+ }
16
+ if (home) {
17
+ return path.join(home, ".config", "aemdm", "config.json");
18
+ }
19
+ return path.join(process.cwd(), ".aemdm.config.json");
20
+ }
21
+ export async function readProfileConfig(env) {
22
+ const configPath = resolveConfigPath(env);
23
+ try {
24
+ const raw = await readFile(configPath, "utf8");
25
+ return profileSchema.parse(JSON.parse(raw));
26
+ }
27
+ catch (error) {
28
+ const typedError = error;
29
+ if (typedError?.code === "ENOENT") {
30
+ return {};
31
+ }
32
+ if (error instanceof z.ZodError) {
33
+ throw new Error(`Invalid aemdm profile config at ${configPath}: ${error.message}`, {
34
+ cause: error,
35
+ });
36
+ }
37
+ if (error instanceof SyntaxError) {
38
+ throw new Error(`Unable to parse aemdm profile config at ${configPath}: ${error.message}`, {
39
+ cause: error,
40
+ });
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+ export async function writeProfileConfig(env, profile) {
46
+ const configPath = resolveConfigPath(env);
47
+ await mkdir(path.dirname(configPath), { recursive: true });
48
+ await writeFile(configPath, `${JSON.stringify(profile, null, 2)}\n`, "utf8");
49
+ return configPath;
50
+ }
@@ -0,0 +1,87 @@
1
+ import { z } from "zod";
2
+ import { CliError } from "./client.js";
3
+ export const DEFAULT_SEO_NAME = "asset";
4
+ export const TRANSFORM_FALLBACK_FORMAT = "avif";
5
+ export const deliveryFormatSchema = z.enum(["gif", "png", "jpg", "jpeg", "webp", "avif"]);
6
+ const qualitySchema = z.number().int().min(1).max(100);
7
+ const dimensionSchema = z.number().int().min(1);
8
+ export function parseSize(value) {
9
+ const match = /^(?<width>\d+)?x(?<height>\d+)?$/i.exec(value.trim());
10
+ if (!match?.groups) {
11
+ throw new CliError("Invalid --size value. Use WxH, Wxx, or xH.");
12
+ }
13
+ const width = match.groups.width ? Number(match.groups.width) : undefined;
14
+ const height = match.groups.height ? Number(match.groups.height) : undefined;
15
+ if (!width && !height) {
16
+ throw new CliError("Invalid --size value. Provide at least width or height.");
17
+ }
18
+ if (width !== undefined) {
19
+ dimensionSchema.parse(width);
20
+ }
21
+ if (height !== undefined) {
22
+ dimensionSchema.parse(height);
23
+ }
24
+ return { width, height };
25
+ }
26
+ export function resolveDimensions(size, width, height) {
27
+ const sizeDimensions = size ? parseSize(size) : {};
28
+ const resolvedWidth = width ?? sizeDimensions.width;
29
+ const resolvedHeight = height ?? sizeDimensions.height;
30
+ if (resolvedWidth !== undefined) {
31
+ dimensionSchema.parse(resolvedWidth);
32
+ }
33
+ if (resolvedHeight !== undefined) {
34
+ dimensionSchema.parse(resolvedHeight);
35
+ }
36
+ return {
37
+ width: resolvedWidth,
38
+ height: resolvedHeight,
39
+ };
40
+ }
41
+ export function buildMetadataUrl(baseUrl, assetId) {
42
+ return `${baseUrl.replace(/\/+$/, "")}/${encodeURIComponent(assetId)}/metadata`;
43
+ }
44
+ export function buildAssetUrl(baseUrl, options) {
45
+ const seoName = options.seoName ?? DEFAULT_SEO_NAME;
46
+ const normalizedBase = baseUrl.replace(/\/+$/, "");
47
+ const encodedAssetId = encodeURIComponent(options.assetId);
48
+ const encodedSeoName = encodeURIComponent(seoName);
49
+ if (options.quality !== undefined) {
50
+ qualitySchema.parse(options.quality);
51
+ }
52
+ if (options.maxQuality !== undefined) {
53
+ qualitySchema.parse(options.maxQuality);
54
+ }
55
+ if (options.width !== undefined) {
56
+ dimensionSchema.parse(options.width);
57
+ }
58
+ if (options.height !== undefined) {
59
+ dimensionSchema.parse(options.height);
60
+ }
61
+ if (options.original) {
62
+ return `${normalizedBase}/${encodedAssetId}/original/as/${encodedSeoName}`;
63
+ }
64
+ const shouldUseTransformRoute = options.format !== undefined ||
65
+ options.width !== undefined ||
66
+ options.height !== undefined ||
67
+ options.quality !== undefined ||
68
+ options.maxQuality !== undefined;
69
+ if (!shouldUseTransformRoute) {
70
+ return `${normalizedBase}/${encodedAssetId}`;
71
+ }
72
+ const format = options.format ?? TRANSFORM_FALLBACK_FORMAT;
73
+ const url = new URL(`${normalizedBase}/${encodedAssetId}/as/${encodedSeoName}.${format}`);
74
+ if (options.width !== undefined) {
75
+ url.searchParams.set("width", String(options.width));
76
+ }
77
+ if (options.height !== undefined) {
78
+ url.searchParams.set("height", String(options.height));
79
+ }
80
+ if (options.quality !== undefined) {
81
+ url.searchParams.set("quality", String(options.quality));
82
+ }
83
+ if (options.maxQuality !== undefined) {
84
+ url.searchParams.set("max-quality", String(options.maxQuality));
85
+ }
86
+ return url.toString();
87
+ }
@@ -0,0 +1,53 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { createWriteStream } from "node:fs";
3
+ import path from "node:path";
4
+ import { Readable } from "node:stream";
5
+ import { pipeline } from "node:stream/promises";
6
+ function valueToString(value) {
7
+ if (Array.isArray(value)) {
8
+ return value.join(", ");
9
+ }
10
+ return value === undefined || value === null ? "" : String(value);
11
+ }
12
+ export function writeLine(stream, line) {
13
+ stream.write(`${line}\n`);
14
+ }
15
+ export function writeJson(stream, value) {
16
+ stream.write(`${JSON.stringify(value, null, 2)}\n`);
17
+ }
18
+ export function formatSearchResults(response) {
19
+ const results = response.hits?.results ?? [];
20
+ if (results.length === 0) {
21
+ return "No results.";
22
+ }
23
+ const rows = [
24
+ ["assetId", "name", "format", "title"],
25
+ ...results.map((result) => [
26
+ valueToString(result.assetId ?? result.id),
27
+ valueToString(result.repositoryMetadata?.["repo:name"]),
28
+ valueToString(result.repositoryMetadata?.["dc:format"]),
29
+ valueToString(result.assetMetadata?.["dc:title"]),
30
+ ]),
31
+ ];
32
+ const widths = rows[0].map((_, columnIndex) => Math.max(...rows.map((row) => row[columnIndex].length)));
33
+ return rows
34
+ .map((row) => row
35
+ .map((cell, columnIndex) => cell.padEnd(widths[columnIndex]))
36
+ .join(" "))
37
+ .join("\n");
38
+ }
39
+ export async function writeBinaryOutput(response, output, stdout) {
40
+ if (!response.body) {
41
+ throw new Error("Response body is empty.");
42
+ }
43
+ const readable = Readable.fromWeb(response.body);
44
+ if (output === "-") {
45
+ for await (const chunk of readable) {
46
+ stdout.write(chunk);
47
+ }
48
+ return;
49
+ }
50
+ await mkdir(path.dirname(output), { recursive: true });
51
+ const fileStream = createWriteStream(output);
52
+ await pipeline(readable, fileStream);
53
+ }
@@ -0,0 +1,95 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { z } from "zod";
3
+ import { CliError } from "./client.js";
4
+ const whereSchema = z.string().trim().min(1);
5
+ export function normalizeSearchField(field) {
6
+ return /^(assetMetadata|repositoryMetadata)\./.test(field)
7
+ ? field
8
+ : `assetMetadata.${field}`;
9
+ }
10
+ export function parseWhereClause(input) {
11
+ const raw = whereSchema.parse(input);
12
+ const separatorIndex = raw.indexOf("=");
13
+ if (separatorIndex < 1) {
14
+ throw new CliError(`Invalid --where clause "${input}". Use field=value.`);
15
+ }
16
+ const field = raw.slice(0, separatorIndex).trim();
17
+ const rawValue = raw.slice(separatorIndex + 1).trim();
18
+ if (!rawValue) {
19
+ throw new CliError(`Invalid --where clause "${input}". Value cannot be empty.`);
20
+ }
21
+ const values = rawValue
22
+ .split(",")
23
+ .map((value) => value.trim())
24
+ .filter(Boolean);
25
+ if (values.length === 0) {
26
+ throw new CliError(`Invalid --where clause "${input}". Value cannot be empty.`);
27
+ }
28
+ return {
29
+ field: normalizeSearchField(field),
30
+ values,
31
+ };
32
+ }
33
+ export function parseSort(input) {
34
+ const trimmed = input.trim();
35
+ const match = /^(.*?):(asc|desc)$/i.exec(trimmed);
36
+ if (!match) {
37
+ return { field: trimmed };
38
+ }
39
+ return {
40
+ field: match[1],
41
+ order: match[2].toUpperCase(),
42
+ };
43
+ }
44
+ export function buildSearchRequest(options) {
45
+ const termClauses = (options.where ?? []).map(parseWhereClause);
46
+ const query = [
47
+ {
48
+ match: {
49
+ mode: "FULLTEXT",
50
+ text: options.text ?? "",
51
+ },
52
+ },
53
+ ];
54
+ for (const clause of termClauses) {
55
+ query.push({
56
+ term: {
57
+ [clause.field]: clause.values,
58
+ },
59
+ });
60
+ }
61
+ const request = { query };
62
+ if (options.limit !== undefined) {
63
+ request.limit = options.limit;
64
+ }
65
+ if (options.cursor) {
66
+ request.cursor = options.cursor;
67
+ }
68
+ if (options.fields && options.fields.length > 0) {
69
+ request.projectedFields = {
70
+ includes: options.fields,
71
+ };
72
+ }
73
+ if (options.sort && options.sort.length > 0) {
74
+ request.sort = options.sort.map(parseSort);
75
+ }
76
+ return request;
77
+ }
78
+ export async function loadRawQuery(input) {
79
+ const rawJson = input.startsWith("@")
80
+ ? await readFile(input.slice(1), "utf8")
81
+ : input;
82
+ try {
83
+ return JSON.parse(rawJson);
84
+ }
85
+ catch (error) {
86
+ throw new CliError(`Unable to parse raw query JSON: ${error instanceof Error ? error.message : String(error)}`);
87
+ }
88
+ }
89
+ export function getAssetIdFromHit(hit) {
90
+ const assetId = hit.assetId ?? hit.id;
91
+ if (!assetId) {
92
+ throw new CliError("Search result did not include an asset identifier.");
93
+ }
94
+ return assetId;
95
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "aemdm",
3
+ "version": "0.1.0",
4
+ "description": "CLI for Adobe Dynamic Media with OpenAPI",
5
+ "license": "Apache-2.0",
6
+ "author": "Chris Pilsworth",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/cpilsworth/aemdm.git"
10
+ },
11
+ "homepage": "https://github.com/cpilsworth/aemdm#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/cpilsworth/aemdm/issues"
14
+ },
15
+ "keywords": [
16
+ "aem",
17
+ "adobe",
18
+ "dynamic-media",
19
+ "cli",
20
+ "openapi"
21
+ ],
22
+ "type": "module",
23
+ "bin": {
24
+ "aemdm": "dist/cli.js"
25
+ },
26
+ "files": [
27
+ "dist/",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.json",
33
+ "dev": "node --enable-source-maps src/cli.ts",
34
+ "lint": "eslint .",
35
+ "test": "vitest run",
36
+ "pack:check": "npm pack --dry-run --cache .npm-cache",
37
+ "prepack": "npm run build",
38
+ "prepublishOnly": "npm run test"
39
+ },
40
+ "engines": {
41
+ "node": ">=20"
42
+ },
43
+ "dependencies": {
44
+ "commander": "^14.0.1",
45
+ "zod": "^4.1.12"
46
+ },
47
+ "devDependencies": {
48
+ "@eslint/js": "^10.0.1",
49
+ "@types/node": "^24.9.1",
50
+ "eslint": "^10.2.0",
51
+ "globals": "^17.5.0",
52
+ "typescript": "^5.9.3",
53
+ "typescript-eslint": "^8.58.2",
54
+ "vitest": "^4.0.7"
55
+ }
56
+ }