@adobe/aem-cli 16.17.1 → 16.18.1

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.
@@ -0,0 +1,198 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import mime from 'mime';
13
+ import processQueue from '@adobe/helix-shared-process-queue';
14
+ import { getFetch } from '../fetch-utils.js';
15
+ import { CONTENT_IO_CONCURRENCY } from './content-shared.js';
16
+
17
+ const DA_ADMIN = 'https://admin.da.live';
18
+
19
+ /** Response header used to page past the per-request list limit (e.g. 1000 items). */
20
+ const LIST_CONTINUATION_HEADER = 'da-continuation-token';
21
+
22
+ /** Safety cap on list pages per directory (avoids infinite loops if the API misbehaves). */
23
+ const LIST_MAX_PAGES = 50000;
24
+
25
+ export function getContentType(ext) {
26
+ return mime.getType(ext) || 'application/octet-stream';
27
+ }
28
+
29
+ export class DaClient {
30
+ constructor(token) {
31
+ this.token = token;
32
+ this.fetch = getFetch(false);
33
+ }
34
+
35
+ get authHeader() {
36
+ return { Authorization: `Bearer ${this.token}` };
37
+ }
38
+
39
+ /**
40
+ * Lists the contents of a directory, following {@link LIST_CONTINUATION_HEADER} until complete.
41
+ * @param {string} org
42
+ * @param {string} repo
43
+ * @param {string} daPath - path starting with /
44
+ * @returns {Promise<Array<{path, name, ext?, lastModified}>>}
45
+ */
46
+ async list(org, repo, daPath) {
47
+ const url = `${DA_ADMIN}/list/${org}/${repo}${daPath}`;
48
+ const aggregated = [];
49
+ let continuation = null;
50
+
51
+ for (let page = 0; page < LIST_MAX_PAGES; page += 1) {
52
+ const headers = { ...this.authHeader };
53
+ if (continuation) {
54
+ headers[LIST_CONTINUATION_HEADER] = continuation;
55
+ }
56
+ // eslint-disable-next-line no-await-in-loop
57
+ const res = await this.fetch(url, { headers });
58
+ if (res.status === 401) {
59
+ throw new Error('Unauthorized: invalid or missing token');
60
+ }
61
+ if (!res.ok) {
62
+ throw new Error(`List failed for ${daPath}: ${res.status} ${res.statusText}`);
63
+ }
64
+ // eslint-disable-next-line no-await-in-loop
65
+ const body = await res.json();
66
+ if (!Array.isArray(body)) {
67
+ throw new Error(`List response for ${daPath} must be a JSON array`);
68
+ }
69
+ aggregated.push(...body);
70
+
71
+ const next = res.headers.get(LIST_CONTINUATION_HEADER);
72
+ if (!next || next === continuation) {
73
+ return aggregated;
74
+ }
75
+ continuation = next;
76
+ }
77
+
78
+ return aggregated;
79
+ }
80
+
81
+ /**
82
+ * Recursively lists all files under a path using a non-recursive queue-based approach.
83
+ * @param {string} org
84
+ * @param {string} repo
85
+ * @param {string} [daPath='/']
86
+ * @param {(discoveredCount: number) => void} [onDiscovered] - cumulative file count per discovery
87
+ * @returns {Promise<Array<{path, name, ext, lastModified}>>}
88
+ */
89
+ async listAll(org, repo, daPath = '/', onDiscovered = undefined) {
90
+ const prefix = `/${org}/${repo}`;
91
+ const files = [];
92
+ let dirsToProcess = [daPath];
93
+
94
+ while (dirsToProcess.length > 0) {
95
+ const nextDirs = [];
96
+ // eslint-disable-next-line no-await-in-loop
97
+ await processQueue(
98
+ dirsToProcess,
99
+ async (currentPath) => {
100
+ const items = await this.list(org, repo, currentPath);
101
+ for (const item of items) {
102
+ if (item.ext !== undefined) {
103
+ files.push(item);
104
+ if (onDiscovered) {
105
+ onDiscovered(files.length);
106
+ }
107
+ } else {
108
+ nextDirs.push(item.path.replace(prefix, '') || '/');
109
+ }
110
+ }
111
+ },
112
+ CONTENT_IO_CONCURRENCY,
113
+ );
114
+ dirsToProcess = nextDirs;
115
+ }
116
+
117
+ return files;
118
+ }
119
+
120
+ /**
121
+ * Fetches the raw content of a file.
122
+ * @returns {Promise<Response|null>}
123
+ */
124
+ async getSource(org, repo, daPath) {
125
+ const url = `${DA_ADMIN}/source/${org}/${repo}${daPath}`;
126
+ const res = await this.fetch(url, { headers: this.authHeader });
127
+ if (res.status === 401) {
128
+ throw new Error('Unauthorized: invalid or missing token');
129
+ }
130
+ if (res.status === 404) {
131
+ return null;
132
+ }
133
+ if (!res.ok) {
134
+ throw new Error(`GET failed for ${daPath}: ${res.status} ${res.statusText}`);
135
+ }
136
+ return res;
137
+ }
138
+
139
+ /**
140
+ * Uploads a file via PUT.
141
+ * @param {string} org
142
+ * @param {string} repo
143
+ * @param {string} daPath
144
+ * @param {Buffer} buffer
145
+ * @param {string} contentType
146
+ * @returns {Promise<object>} API response body
147
+ */
148
+ async putSource(org, repo, daPath, buffer, contentType) {
149
+ const url = `${DA_ADMIN}/source/${org}/${repo}${daPath}`;
150
+ const res = await this.fetch(url, {
151
+ method: 'PUT',
152
+ headers: { ...this.authHeader, 'Content-Type': contentType },
153
+ body: buffer,
154
+ });
155
+ if (res.status === 401) {
156
+ throw new Error('Unauthorized: invalid or missing token');
157
+ }
158
+ if (!res.ok) {
159
+ throw new Error(`PUT failed for ${daPath}: ${res.status} ${res.statusText}`);
160
+ }
161
+ return res.json();
162
+ }
163
+
164
+ /**
165
+ * Deletes a file or folder. Idempotent.
166
+ */
167
+ async deleteSource(org, repo, daPath) {
168
+ const url = `${DA_ADMIN}/source/${org}/${repo}${daPath}`;
169
+ const res = await this.fetch(url, {
170
+ method: 'DELETE',
171
+ headers: this.authHeader,
172
+ });
173
+ if (res.status === 401) {
174
+ throw new Error('Unauthorized: invalid or missing token');
175
+ }
176
+ return res.ok || res.status === 204;
177
+ }
178
+
179
+ /**
180
+ * Returns the current lastModified for a file via a HEAD request.
181
+ * @param {string} org
182
+ * @param {string} repo
183
+ * @param {string} daPath - e.g. /blog/post.html
184
+ * @returns {Promise<number|null>}
185
+ */
186
+ async getRemoteLastModified(org, repo, daPath) {
187
+ const url = `${DA_ADMIN}/source/${org}/${repo}${daPath}`;
188
+ const res = await this.fetch(url, { method: 'HEAD', headers: this.authHeader });
189
+ if (res.status === 401) {
190
+ throw new Error('Unauthorized: invalid or missing token');
191
+ }
192
+ if (!res.ok) {
193
+ return null;
194
+ }
195
+ const lastModified = res.headers.get('last-modified');
196
+ return lastModified ? new Date(lastModified).getTime() : null;
197
+ }
198
+ }
@@ -0,0 +1,209 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import http from 'http';
13
+ import path from 'path';
14
+ import fse from 'fs-extra';
15
+ import open from 'open';
16
+ import { ensureGitIgnored } from './content-git.js';
17
+
18
+ const IMS_ORIGIN = 'https://ims-na1.adobelogin.com';
19
+ const CLIENT_ID = 'darkalley';
20
+ const SCOPE = 'ab.manage,AdobeID,gnav,openid,org.read,read_organizations,session,aem.frontend.all,additional_info.ownerOrg,additional_info.projectedProductContext,account_cluster.read';
21
+ const CALLBACK_PORT = 9898;
22
+ const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/callback`;
23
+
24
+ /** Token file stored in the project's .hlx folder, alongside the site token. */
25
+ export const DA_TOKEN_FILE = path.join('.hlx', '.da-token.json');
26
+
27
+ // ─── Token storage ───────────────────────────────────────────────────────────
28
+
29
+ async function loadStoredToken(tokenFile) {
30
+ if (!await fse.pathExists(tokenFile)) {
31
+ return null;
32
+ }
33
+ try {
34
+ return await fse.readJson(tokenFile);
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Saves the DA token to the project's .hlx folder and ensures the file is git-ignored,
42
+ * following the same pattern as saveSiteTokenToFile in config-utils.js.
43
+ * @param {string} projectDir
44
+ * @param {object} tokenData
45
+ */
46
+ async function saveDaTokenToFile(projectDir, tokenData) {
47
+ const tokenFile = path.join(projectDir, DA_TOKEN_FILE);
48
+ await fse.ensureDir(path.dirname(tokenFile));
49
+ await fse.writeJson(tokenFile, tokenData, { spaces: 2 });
50
+
51
+ await ensureGitIgnored(projectDir, DA_TOKEN_FILE);
52
+ }
53
+
54
+ // ─── Token validity ──────────────────────────────────────────────────────────
55
+
56
+ function isTokenExpired(stored) {
57
+ if (stored.expires_at) {
58
+ // 60 second early buffer for clock skew
59
+ return Date.now() >= (stored.expires_at - 60_000);
60
+ }
61
+ // legacy stored tokens without expires_at — treat as expired
62
+ return true;
63
+ }
64
+
65
+ // ─── OAuth flow ──────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Starts a local HTTP server that handles the implicit flow callback.
69
+ *
70
+ * IMS redirects to http://localhost:{port}/callback#access_token=TOKEN
71
+ * The fragment never reaches the server, so /callback serves a tiny HTML page
72
+ * that reads the fragment via JS and forwards the token to /token, then
73
+ * redirects the browser to https://tools.aem.live/cli/logged-in on success.
74
+ *
75
+ * @returns {Promise<{token: string, expiresIn: number|null}>}
76
+ */
77
+ function waitForToken() {
78
+ return new Promise((resolve, reject) => {
79
+ let timeout;
80
+ const server = http.createServer((req, res) => {
81
+ const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
82
+
83
+ // Step 1: IMS lands here with the token in the fragment.
84
+ // Serve a page that extracts it and calls /token.
85
+ if (url.pathname === '/callback') {
86
+ res.writeHead(200, { 'Content-Type': 'text/html' });
87
+ res.end(`<!DOCTYPE html><html><head><title>Logging in...</title></head><body>
88
+ <script>
89
+ const p = new URLSearchParams(window.location.hash.substring(1));
90
+ const token = p.get('access_token');
91
+ const expiresIn = p.get('expires_in');
92
+ const error = p.get('error');
93
+ const dest = token
94
+ ? '/token?access_token=' + encodeURIComponent(token) + (expiresIn ? '&expires_in=' + encodeURIComponent(expiresIn) : '')
95
+ : '/token?error=' + encodeURIComponent(error || 'unknown');
96
+ const loggedInUrl = 'https://tools.aem.live/cli/logged-in';
97
+ if (!token) {
98
+ fetch(dest);
99
+ document.body.innerHTML = '<h2>Login failed.</h2>';
100
+ const errP = document.createElement('p');
101
+ errP.textContent = error || 'Unknown error';
102
+ document.body.appendChild(errP);
103
+ } else {
104
+ fetch(dest)
105
+ .then(() => { window.location.href = loggedInUrl; })
106
+ .catch(() => {
107
+ document.body.innerHTML = '<h2>Login failed.</h2><p>Could not complete login.</p>';
108
+ });
109
+ }
110
+ </script></body></html>`);
111
+ return;
112
+ }
113
+
114
+ // Step 2: the page above calls /token with the token as a query param.
115
+ if (url.pathname === '/token') {
116
+ const token = url.searchParams.get('access_token');
117
+ const expiresIn = url.searchParams.get('expires_in');
118
+ const error = url.searchParams.get('error');
119
+ res.writeHead(200);
120
+ res.end();
121
+ clearTimeout(timeout);
122
+ server.close();
123
+ if (token) {
124
+ resolve({ token, expiresIn: expiresIn ? parseInt(expiresIn, 10) : null });
125
+ } else {
126
+ reject(new Error(`Login failed: ${error || 'unknown error'}`));
127
+ }
128
+ return;
129
+ }
130
+
131
+ res.writeHead(404);
132
+ res.end();
133
+ });
134
+
135
+ server.listen(CALLBACK_PORT, 'localhost');
136
+ server.on('error', (err) => reject(new Error(`Could not start callback server on port ${CALLBACK_PORT}: ${err.message}`)));
137
+
138
+ timeout = setTimeout(() => {
139
+ server.close();
140
+ reject(new Error('Login timed out (5 minutes). Please try again.'));
141
+ }, 5 * 60 * 1000);
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Runs the implicit OAuth login flow.
147
+ * @param {object} log
148
+ * @param {string} projectDir
149
+ * @returns {Promise<string>} access token
150
+ */
151
+ async function login(log, projectDir) {
152
+ const params = new URLSearchParams({
153
+ response_type: 'token',
154
+ client_id: CLIENT_ID,
155
+ scope: SCOPE,
156
+ redirect_uri: REDIRECT_URI,
157
+ });
158
+ const authUrl = `${IMS_ORIGIN}/ims/authorize/v2?${params}`;
159
+
160
+ log.info('Opening browser for da.live login...');
161
+ log.info(`If the browser does not open automatically, visit:\n ${authUrl}\n`);
162
+ log.info('Waiting for login to complete...');
163
+
164
+ await open(authUrl);
165
+
166
+ const { token, expiresIn } = await waitForToken();
167
+
168
+ await saveDaTokenToFile(projectDir, {
169
+ access_token: token,
170
+ expires_at: expiresIn ? Date.now() + (expiresIn * 1000) : null,
171
+ });
172
+
173
+ log.info(`Login successful. Token saved to ${path.join(projectDir, DA_TOKEN_FILE)}`);
174
+ return token;
175
+ }
176
+
177
+ // ─── Public API ──────────────────────────────────────────────────────────────
178
+
179
+ /**
180
+ * Returns a valid da.live access token. Triggers browser login if needed.
181
+ *
182
+ * Priority:
183
+ * 1. Caller-supplied token (--token flag) — used as-is, not persisted
184
+ * 2. Stored token in .hlx/.da-token.json that is still valid
185
+ * 3. Full browser implicit login flow
186
+ *
187
+ * @param {object} log
188
+ * @param {string} [override] token supplied via CLI --token flag
189
+ * @param {string} projectDir project root directory (token stored in .hlx/ here)
190
+ * @returns {Promise<string>} valid access token
191
+ */
192
+ export async function getValidToken(log, override, projectDir) {
193
+ if (override) {
194
+ return override;
195
+ }
196
+
197
+ const tokenFile = path.join(projectDir, DA_TOKEN_FILE);
198
+ const stored = await loadStoredToken(tokenFile);
199
+
200
+ if (stored?.access_token && !isTokenExpired(stored)) {
201
+ return stored.access_token;
202
+ }
203
+
204
+ if (stored?.access_token) {
205
+ log.info('Stored token has expired. Re-authenticating...');
206
+ }
207
+
208
+ return login(log, projectDir);
209
+ }
@@ -0,0 +1,123 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import fse from 'fs-extra';
15
+ import git from 'isomorphic-git';
16
+ import { createTwoFilesPatch } from 'diff';
17
+ import chalk from 'chalk-template';
18
+ import { DaClient } from './da-api.js';
19
+ import { getValidToken } from './da-auth.js';
20
+ import { CONTENT_DIR, CONFIG_FILE } from './content-shared.js';
21
+
22
+ function printPatch(patch) {
23
+ for (const line of patch.split('\n')) {
24
+ if (line.startsWith('+++') || line.startsWith('---')) {
25
+ process.stdout.write(chalk`{bold ${line}}\n`);
26
+ } else if (line.startsWith('@@')) {
27
+ process.stdout.write(chalk`{cyan ${line}}\n`);
28
+ } else if (line.startsWith('+')) {
29
+ process.stdout.write(chalk`{green ${line}}\n`);
30
+ } else if (line.startsWith('-')) {
31
+ process.stdout.write(chalk`{red ${line}}\n`);
32
+ } else {
33
+ process.stdout.write(`${line}\n`);
34
+ }
35
+ }
36
+ }
37
+
38
+ export default class DiffCommand {
39
+ constructor(logger) {
40
+ this.log = logger;
41
+ this._dir = process.cwd();
42
+ this._filePath = null;
43
+ }
44
+
45
+ withDirectory(dir) {
46
+ this._dir = dir;
47
+ return this;
48
+ }
49
+
50
+ withToken(token) {
51
+ this._token = token;
52
+ return this;
53
+ }
54
+
55
+ withFilePath(filePath) {
56
+ this._filePath = filePath || null;
57
+ return this;
58
+ }
59
+
60
+ async run() {
61
+ const { log } = this;
62
+ const contentDir = path.resolve(this._dir, CONTENT_DIR);
63
+ const configPath = path.join(contentDir, CONFIG_FILE);
64
+
65
+ if (!await fse.pathExists(configPath)) {
66
+ throw new Error('No config found. Run \'aem content clone\' first.');
67
+ }
68
+ const { org, repo } = await fse.readJson(configPath);
69
+
70
+ const matrix = await git.statusMatrix({ fs, dir: contentDir });
71
+ let changedPaths = matrix
72
+ .filter(([, head, workdir]) => (head === 0 && workdir === 2)
73
+ || (head === 1 && workdir === 2)
74
+ || (head === 1 && workdir === 0))
75
+ .map(([filepath]) => `/${filepath}`);
76
+
77
+ if (this._filePath) {
78
+ const needle = this._filePath.startsWith('/') ? this._filePath : `/${this._filePath}`;
79
+ changedPaths = changedPaths.filter((p) => p === needle);
80
+ if (changedPaths.length === 0) {
81
+ log.info(`No local changes detected for ${needle}`);
82
+ return;
83
+ }
84
+ }
85
+
86
+ if (changedPaths.length === 0) {
87
+ log.info('Nothing to diff. No local changes vs last commit.');
88
+ return;
89
+ }
90
+
91
+ const token = await getValidToken(log, this._token, this._dir);
92
+ const client = new DaClient(token);
93
+
94
+ await Promise.all(changedPaths.map(async (daPath) => {
95
+ const localPath = path.join(contentDir, ...daPath.split('/').filter(Boolean));
96
+
97
+ const localBuffer = await fse.pathExists(localPath)
98
+ ? await fse.readFile(localPath)
99
+ : Buffer.from('');
100
+
101
+ const remoteRes = await client.getSource(org, repo, daPath);
102
+
103
+ const localText = localBuffer.toString('utf-8');
104
+ const remoteText = remoteRes ? await remoteRes.text() : '';
105
+
106
+ const patch = createTwoFilesPatch(
107
+ `a${daPath}`,
108
+ `b${daPath}`,
109
+ remoteText,
110
+ localText,
111
+ '',
112
+ '',
113
+ { context: 3 },
114
+ );
115
+
116
+ if (!patch.includes('\n@@')) {
117
+ return;
118
+ }
119
+
120
+ printPatch(patch);
121
+ }));
122
+ }
123
+ }
@@ -0,0 +1,45 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import { getOrCreateLogger } from '../log-common.js';
13
+
14
+ export default function diff() {
15
+ let executor;
16
+ return {
17
+ set executor(value) {
18
+ executor = value;
19
+ },
20
+ command: 'diff [path]',
21
+ description: 'Show diff between local and remote content',
22
+ builder: (yargs) => {
23
+ yargs
24
+ .positional('path', {
25
+ describe: 'File to diff (e.g. /blog/post.html). Diffs all modified files if omitted.',
26
+ type: 'string',
27
+ })
28
+ .option('token', {
29
+ describe: 'IMS Bearer token for da.live authentication',
30
+ type: 'string',
31
+ })
32
+ .help();
33
+ },
34
+ handler: async (argv) => {
35
+ if (!executor) {
36
+ const DiffCommand = (await import('./diff.cmd.js')).default;
37
+ executor = new DiffCommand(getOrCreateLogger(argv));
38
+ }
39
+ await executor
40
+ .withToken(argv.token)
41
+ .withFilePath(argv.path)
42
+ .run();
43
+ },
44
+ };
45
+ }