cli-profile-manager 0.0.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,252 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import inquirer from 'inquirer';
4
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { getConfig, updateConfig, getProfilePath } from '../utils/config.js';
7
+ import { readProfileMetadata } from '../utils/snapshot.js';
8
+ import {
9
+ getGitHubToken,
10
+ getGitHubUsername,
11
+ createProfilePR,
12
+ fetchRepoIndex,
13
+ getCredentialSetupInstructions,
14
+ authenticateWithDeviceFlow
15
+ } from '../utils/auth.js';
16
+
17
+ /**
18
+ * Publish a local profile to the marketplace via a direct PR.
19
+ * Falls back to OAuth device flow if git credentials lack access.
20
+ */
21
+ export async function publishProfile(name, options) {
22
+ const profilePath = getProfilePath(name);
23
+
24
+ if (!existsSync(profilePath)) {
25
+ console.log(chalk.red(`Profile not found: ${name}`));
26
+ console.log(chalk.dim(' List local profiles with: cpm local'));
27
+ process.exit(1);
28
+ }
29
+
30
+ const metadata = readProfileMetadata(name);
31
+
32
+ if (!metadata) {
33
+ console.log(chalk.red('Invalid profile: missing metadata'));
34
+ process.exit(1);
35
+ }
36
+
37
+ const contents = metadata.contents || {};
38
+ const hasContent = Object.values(contents).some(items => items && items.length > 0);
39
+ if (!hasContent) {
40
+ console.log(chalk.red('Profile has no functional content (commands, hooks, skills, etc.)'));
41
+ console.log(chalk.dim(' Profiles must contain at least one functional customization.'));
42
+ process.exit(1);
43
+ }
44
+
45
+ console.log('');
46
+ console.log(chalk.bold('Publish Profile to Marketplace'));
47
+ console.log(chalk.dim('-'.repeat(50)));
48
+ console.log('');
49
+
50
+ const spinner = ora('Checking GitHub credentials...').start();
51
+ let token = getGitHubToken();
52
+ let useFork = false;
53
+
54
+ if (!token) {
55
+ spinner.warn(chalk.yellow('No cached GitHub credentials found.'));
56
+ console.log(chalk.dim(' Falling back to browser authentication...'));
57
+ token = await authenticateWithDeviceFlow();
58
+ useFork = true;
59
+ } else {
60
+ spinner.succeed(chalk.green('Found GitHub credentials.'));
61
+ }
62
+
63
+ let author;
64
+ const userSpinner = ora('Verifying identity...').start();
65
+ try {
66
+ author = await getGitHubUsername(token);
67
+ userSpinner.succeed(chalk.green(`Authenticated as ${chalk.bold(author)}`));
68
+ } catch (error) {
69
+ userSpinner.fail(chalk.red(error.message));
70
+ process.exit(1);
71
+ }
72
+
73
+ console.log('');
74
+ console.log(chalk.cyan(' Name: ') + name);
75
+ console.log(chalk.cyan(' Version: ') + (metadata.version || '1.0.0'));
76
+ console.log(chalk.cyan(' Files: ') + (metadata.files || []).length);
77
+ if (metadata.description) {
78
+ console.log(chalk.cyan(' Desc: ') + metadata.description);
79
+ }
80
+
81
+ for (const [category, items] of Object.entries(contents)) {
82
+ if (items && items.length > 0) {
83
+ const display = category === 'commands'
84
+ ? items.map(i => `/${i}`).join(', ')
85
+ : items.join(', ');
86
+ console.log(chalk.cyan(` ${category}: `) + chalk.dim(display));
87
+ }
88
+ }
89
+ console.log('');
90
+
91
+ const { confirm } = await inquirer.prompt([{
92
+ type: 'confirm',
93
+ name: 'confirm',
94
+ message: `Publish ${chalk.cyan(author + '/' + name)} to the marketplace?`,
95
+ default: true
96
+ }]);
97
+
98
+ if (!confirm) {
99
+ console.log(chalk.yellow('Aborted.'));
100
+ process.exit(0);
101
+ }
102
+
103
+ const config = await getConfig();
104
+
105
+ await attemptPublish(token, config, { author, name, metadata, profilePath, useFork });
106
+ }
107
+
108
+ /**
109
+ * Attempt to publish. On 403 (insufficient token scope), fall back to
110
+ * OAuth device flow and retry with a fork-based PR.
111
+ */
112
+ async function attemptPublish(token, config, { author, name, metadata, profilePath, useFork }) {
113
+ const publishSpinner = ora('Creating pull request...').start();
114
+
115
+ try {
116
+ const pr = await doPublish(token, config, { author, name, metadata, profilePath, useFork });
117
+ publishSpinner.succeed(chalk.green('Pull request created!'));
118
+ console.log('');
119
+ console.log(chalk.cyan(' PR: ') + pr.html_url);
120
+ console.log('');
121
+ console.log(chalk.dim('A maintainer will review and merge your profile.'));
122
+ console.log('');
123
+ } catch (error) {
124
+ if (error.message.includes('403') && !useFork) {
125
+ publishSpinner.warn(chalk.yellow('Credentials lack write access to marketplace repo.'));
126
+ console.log(chalk.dim(' Falling back to browser authentication...'));
127
+ console.log('');
128
+
129
+ const deviceToken = await authenticateWithDeviceFlow();
130
+
131
+ const retrySpinner = ora('Retrying with fork-based PR...').start();
132
+ try {
133
+ const pr = await doPublish(deviceToken, config, { author, name, metadata, profilePath, useFork: true });
134
+ retrySpinner.succeed(chalk.green('Pull request created!'));
135
+ console.log('');
136
+ console.log(chalk.cyan(' PR: ') + pr.html_url);
137
+ console.log('');
138
+ console.log(chalk.dim('A maintainer will review and merge your profile.'));
139
+ console.log('');
140
+ } catch (retryError) {
141
+ retrySpinner.fail(chalk.red(`Publish failed: ${retryError.message}`));
142
+ process.exit(1);
143
+ }
144
+ } else {
145
+ publishSpinner.fail(chalk.red(`Publish failed: ${error.message}`));
146
+ process.exit(1);
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Core publish logic: fetch index, prepare metadata, create PR.
153
+ */
154
+ async function doPublish(token, config, { author, name, metadata, profilePath, useFork }) {
155
+ const index = await fetchRepoIndex(token, config.marketplaceRepo);
156
+
157
+ const publishMetadata = {
158
+ ...metadata,
159
+ author,
160
+ publishedAt: new Date().toISOString()
161
+ };
162
+
163
+ index.profiles = (index.profiles || []).filter(
164
+ p => !(p.author === author && p.name === name)
165
+ );
166
+ index.profiles.push({
167
+ name,
168
+ author,
169
+ version: publishMetadata.version || '1.0.0',
170
+ description: publishMetadata.description || '',
171
+ tags: publishMetadata.tags || [],
172
+ downloads: 0,
173
+ stars: 0,
174
+ createdAt: publishMetadata.publishedAt,
175
+ contents: publishMetadata.contents || {}
176
+ });
177
+ index.lastUpdated = new Date().toISOString();
178
+
179
+ const profileFiles = readProfileFiles(profilePath);
180
+
181
+ const pr = await createProfilePR(token, config.marketplaceRepo, {
182
+ author,
183
+ name,
184
+ profileJson: JSON.stringify(publishMetadata, null, 2),
185
+ profileFiles,
186
+ indexUpdate: JSON.stringify(index, null, 2)
187
+ }, { useFork });
188
+
189
+ return pr;
190
+ }
191
+
192
+ /**
193
+ * Read all content files from a profile directory (excluding profile.json).
194
+ * Returns an array of { path, content } pairs with forward-slash paths.
195
+ */
196
+ function readProfileFiles(profileDir) {
197
+ const files = [];
198
+
199
+ function walk(dir, relativePath = '') {
200
+ const entries = readdirSync(dir);
201
+ for (const entry of entries) {
202
+ if (!relativePath && entry === 'profile.json') continue;
203
+
204
+ const fullPath = join(dir, entry);
205
+ const relPath = relativePath ? `${relativePath}/${entry}` : entry;
206
+ const stat = statSync(fullPath);
207
+
208
+ if (stat.isDirectory()) {
209
+ walk(fullPath, relPath);
210
+ } else {
211
+ files.push({ path: relPath, content: readFileSync(fullPath, 'utf-8') });
212
+ }
213
+ }
214
+ }
215
+
216
+ walk(profileDir);
217
+ return files;
218
+ }
219
+
220
+ /**
221
+ * Set a custom marketplace repository
222
+ */
223
+ export async function setRepository(repository) {
224
+ if (!/^[a-z0-9-]+\/[a-z0-9-]+$/i.test(repository)) {
225
+ console.log(chalk.red('Invalid repository format. Use: owner/repo'));
226
+ process.exit(1);
227
+ }
228
+
229
+ const spinner = ora('Validating repository...').start();
230
+
231
+ try {
232
+ const { default: fetch } = await import('node-fetch');
233
+
234
+ const response = await fetch(
235
+ `https://raw.githubusercontent.com/${repository}/main/index.json`
236
+ );
237
+
238
+ if (!response.ok && response.status !== 404) {
239
+ throw new Error(`Repository not accessible: ${response.status}`);
240
+ }
241
+
242
+ await updateConfig({ marketplaceRepo: repository });
243
+
244
+ spinner.succeed(chalk.green(`Repository set to: ${chalk.bold(repository)}`));
245
+ console.log('');
246
+ console.log(chalk.dim('Browse profiles with: ') + chalk.cyan('cpm list'));
247
+
248
+ } catch (error) {
249
+ spinner.fail(chalk.red(`Failed to set repository: ${error.message}`));
250
+ process.exit(1);
251
+ }
252
+ }
@@ -0,0 +1,403 @@
1
+ import { execSync } from 'child_process';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+
5
+ const GITHUB_API = 'https://api.github.com';
6
+ const HEADERS_BASE = {
7
+ 'Accept': 'application/vnd.github+json',
8
+ 'User-Agent': 'cli-profile-manager'
9
+ };
10
+
11
+ // TODO: Replace with a new OAuth App client ID registered under brrichards.
12
+ // The current ID is from the original brennanr9 account.
13
+ // Setup (one-time, by the cpm maintainer -- NOT end users):
14
+ // 1. https://github.com/settings/applications/new
15
+ // 2. Enable "Device Flow" on the app settings page
16
+ // 3. Paste the client_id here and publish to npm
17
+ // The client_id is public and safe to ship in source -- no secret needed.
18
+ const OAUTH_CLIENT_ID = 'Ov23liNXmVPsBOt1a3Q9';
19
+
20
+ function authHeaders(token) {
21
+ return { ...HEADERS_BASE, 'Authorization': `Bearer ${token}` };
22
+ }
23
+
24
+ async function getFetch() {
25
+ const { default: fetch } = await import('node-fetch');
26
+ return fetch;
27
+ }
28
+
29
+ /**
30
+ * Retrieve a GitHub token from Git Credential Manager.
31
+ * Works cross-platform (Windows, macOS, Linux) -- delegates to
32
+ * whatever credential helper is configured for git.
33
+ */
34
+ export function getGitHubToken() {
35
+ try {
36
+ const input = 'protocol=https\nhost=github.com\n\n';
37
+ const output = execSync('git credential fill', {
38
+ input,
39
+ encoding: 'utf-8',
40
+ timeout: 10000,
41
+ stdio: ['pipe', 'pipe', 'pipe']
42
+ });
43
+
44
+ const creds = {};
45
+ for (const line of output.trim().split('\n')) {
46
+ const [key, ...rest] = line.split('=');
47
+ creds[key] = rest.join('=');
48
+ }
49
+
50
+ return creds.password || null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Authenticate via the GitHub OAuth device flow.
58
+ * Opens a browser prompt for the user to authorize -- no PAT needed.
59
+ * Returns a short-lived access token.
60
+ */
61
+ export async function authenticateWithDeviceFlow() {
62
+ const fetch = await getFetch();
63
+
64
+ // Step 1: Request a device code
65
+ const codeResponse = await fetch('https://github.com/login/device/code', {
66
+ method: 'POST',
67
+ headers: {
68
+ 'Accept': 'application/json',
69
+ 'Content-Type': 'application/json'
70
+ },
71
+ body: JSON.stringify({
72
+ client_id: OAUTH_CLIENT_ID,
73
+ scope: 'public_repo'
74
+ })
75
+ });
76
+
77
+ if (!codeResponse.ok) {
78
+ throw new Error(`Device flow initiation failed: ${codeResponse.status}`);
79
+ }
80
+
81
+ const { device_code, user_code, verification_uri, interval, expires_in } = await codeResponse.json();
82
+
83
+ // Step 2: Show the user the code
84
+ console.log('');
85
+ console.log(chalk.yellow(` Open: ${chalk.bold(verification_uri)}`));
86
+ console.log(chalk.yellow(` Enter code: ${chalk.bold(user_code)}`));
87
+ console.log('');
88
+
89
+ // Step 3: Poll for authorization
90
+ const spinner = ora('Waiting for authorization...').start();
91
+ const pollInterval = (interval || 5) * 1000;
92
+ const deadline = Date.now() + (expires_in || 900) * 1000;
93
+
94
+ while (Date.now() < deadline) {
95
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
96
+
97
+ const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
98
+ method: 'POST',
99
+ headers: {
100
+ 'Accept': 'application/json',
101
+ 'Content-Type': 'application/json'
102
+ },
103
+ body: JSON.stringify({
104
+ client_id: OAUTH_CLIENT_ID,
105
+ device_code,
106
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
107
+ })
108
+ });
109
+
110
+ const data = await tokenResponse.json();
111
+
112
+ if (data.access_token) {
113
+ spinner.succeed(chalk.green('Authorized.'));
114
+ return data.access_token;
115
+ }
116
+
117
+ if (data.error === 'authorization_pending') {
118
+ continue;
119
+ }
120
+
121
+ if (data.error === 'slow_down') {
122
+ await new Promise(resolve => setTimeout(resolve, 5000));
123
+ continue;
124
+ }
125
+
126
+ if (data.error === 'expired_token') {
127
+ spinner.fail('Authorization expired. Please try again.');
128
+ throw new Error('Device flow expired.');
129
+ }
130
+
131
+ if (data.error === 'access_denied') {
132
+ spinner.fail('Authorization denied.');
133
+ throw new Error('User denied authorization.');
134
+ }
135
+
136
+ spinner.fail(`Authorization failed: ${data.error}`);
137
+ throw new Error(data.error_description || data.error);
138
+ }
139
+
140
+ spinner.fail('Authorization timed out.');
141
+ throw new Error('Device flow timed out.');
142
+ }
143
+
144
+ /**
145
+ * Get the GitHub username associated with a token.
146
+ */
147
+ export async function getGitHubUsername(token) {
148
+ const fetch = await getFetch();
149
+
150
+ const response = await fetch(`${GITHUB_API}/user`, {
151
+ headers: authHeaders(token)
152
+ });
153
+
154
+ if (!response.ok) {
155
+ if (response.status === 401) {
156
+ throw new Error('GitHub credentials are expired or invalid. Re-authenticate with git and try again.');
157
+ }
158
+ throw new Error(`GitHub API error: ${response.status}`);
159
+ }
160
+
161
+ const user = await response.json();
162
+ return user.login;
163
+ }
164
+
165
+ /**
166
+ * Ensure a fork of the marketplace repo exists under the authenticated user.
167
+ * Returns the full name of the fork (e.g., "username/cli-profile-manager").
168
+ */
169
+ async function ensureFork(token, upstreamRepo) {
170
+ const fetch = await getFetch();
171
+ const headers = { ...authHeaders(token), 'Content-Type': 'application/json' };
172
+
173
+ const userResponse = await fetch(`${GITHUB_API}/user`, { headers: authHeaders(token) });
174
+ const user = await userResponse.json();
175
+ const repoName = upstreamRepo.split('/')[1];
176
+ const forkFullName = `${user.login}/${repoName}`;
177
+
178
+ // Check if the fork exists
179
+ const checkResponse = await fetch(`${GITHUB_API}/repos/${forkFullName}`, {
180
+ headers: authHeaders(token)
181
+ });
182
+
183
+ if (checkResponse.ok) {
184
+ const fork = await checkResponse.json();
185
+ if (fork.fork) {
186
+ return forkFullName;
187
+ }
188
+ }
189
+
190
+ // Create the fork
191
+ const forkResponse = await fetch(`${GITHUB_API}/repos/${upstreamRepo}/forks`, {
192
+ method: 'POST',
193
+ headers,
194
+ body: JSON.stringify({ default_branch_only: true })
195
+ });
196
+
197
+ if (!forkResponse.ok) {
198
+ const err = await forkResponse.json().catch(() => ({}));
199
+ throw new Error(`Failed to fork ${upstreamRepo}: ${err.message || forkResponse.status}`);
200
+ }
201
+
202
+ const forkData = await forkResponse.json();
203
+
204
+ // Wait for the fork to be ready (GitHub creates forks asynchronously)
205
+ for (let i = 0; i < 30; i++) {
206
+ await new Promise(resolve => setTimeout(resolve, 2000));
207
+ const readyCheck = await fetch(`${GITHUB_API}/repos/${forkData.full_name}/git/ref/heads/main`, {
208
+ headers: authHeaders(token)
209
+ });
210
+ if (readyCheck.ok) {
211
+ return forkData.full_name;
212
+ }
213
+ }
214
+
215
+ throw new Error('Fork creation timed out. Please try again.');
216
+ }
217
+
218
+ /**
219
+ * Create a pull request on the marketplace repo with profile files.
220
+ *
221
+ * Uses the Git Data API (blobs -> tree -> commit -> ref -> PR).
222
+ * If `forkRepo` is provided, writes to the fork and opens a cross-repo PR.
223
+ */
224
+ export async function createProfilePR(token, repo, { author, name, profileJson, profileFiles, indexUpdate }, { useFork = false } = {}) {
225
+ const fetch = await getFetch();
226
+ const targetRepo = useFork ? await ensureFork(token, repo) : repo;
227
+ const headers = { ...authHeaders(token), 'Content-Type': 'application/json' };
228
+
229
+ async function api(method, path, body, apiRepo = targetRepo) {
230
+ const response = await fetch(`${GITHUB_API}/repos/${apiRepo}${path}`, {
231
+ method,
232
+ headers,
233
+ body: body ? JSON.stringify(body) : undefined
234
+ });
235
+
236
+ if (!response.ok) {
237
+ const errorData = await response.json().catch(() => ({}));
238
+ throw new Error(`GitHub API ${method} ${path} failed (${response.status}): ${errorData.message || 'Unknown error'}`);
239
+ }
240
+
241
+ return response.json();
242
+ }
243
+
244
+ // 1. Get the SHA of the main branch (always from upstream for consistency)
245
+ const mainRef = await api('GET', '/git/ref/heads/main', null, repo);
246
+ const baseSha = mainRef.object.sha;
247
+
248
+ // 2. Get the base commit's tree
249
+ const baseCommit = await api('GET', `/git/commits/${baseSha}`, null, repo);
250
+ const baseTreeSha = baseCommit.tree.sha;
251
+
252
+ // 3. Create blobs for each profile file
253
+ const treeEntries = [];
254
+
255
+ // profile.json blob
256
+ const profileBlob = await api('POST', '/git/blobs', {
257
+ content: Buffer.from(profileJson).toString('base64'),
258
+ encoding: 'base64'
259
+ });
260
+ treeEntries.push({
261
+ path: `profiles/${author}/${name}/profile.json`,
262
+ mode: '100644',
263
+ type: 'blob',
264
+ sha: profileBlob.sha
265
+ });
266
+
267
+ // Individual profile files (commands, hooks, skills, CLAUDE.md, etc.)
268
+ for (const file of profileFiles) {
269
+ const blob = await api('POST', '/git/blobs', {
270
+ content: Buffer.from(file.content).toString('base64'),
271
+ encoding: 'base64'
272
+ });
273
+ treeEntries.push({
274
+ path: `profiles/${author}/${name}/${file.path}`,
275
+ mode: '100644',
276
+ type: 'blob',
277
+ sha: blob.sha
278
+ });
279
+ }
280
+
281
+ // index.json blob
282
+ const indexBlob = await api('POST', '/git/blobs', {
283
+ content: Buffer.from(indexUpdate).toString('base64'),
284
+ encoding: 'base64'
285
+ });
286
+ treeEntries.push({
287
+ path: 'index.json',
288
+ mode: '100644',
289
+ type: 'blob',
290
+ sha: indexBlob.sha
291
+ });
292
+
293
+ // 4. Create a new tree with all profile files + updated index
294
+ const tree = await api('POST', '/git/trees', {
295
+ base_tree: baseTreeSha,
296
+ tree: treeEntries
297
+ });
298
+
299
+ // 5. Create a commit
300
+ const commit = await api('POST', '/git/commits', {
301
+ message: `Add profile: ${author}/${name}`,
302
+ tree: tree.sha,
303
+ parents: [baseSha]
304
+ });
305
+
306
+ // 6. Create a branch on the target repo
307
+ const branchName = `profile-submission/${author}/${name}`;
308
+ try {
309
+ await api('POST', '/git/refs', {
310
+ ref: `refs/heads/${branchName}`,
311
+ sha: commit.sha
312
+ });
313
+ } catch (e) {
314
+ // Branch exists from a previous attempt -- force update it
315
+ if (e.message.includes('422')) {
316
+ await api('PATCH', `/git/refs/heads/${branchName}`, {
317
+ sha: commit.sha,
318
+ force: true
319
+ });
320
+ } else {
321
+ throw e;
322
+ }
323
+ }
324
+
325
+ // 7. Create the pull request (always targets upstream)
326
+ const prHead = useFork ? `${targetRepo.split('/')[0]}:${branchName}` : branchName;
327
+ const pr = await api('POST', '/pulls', {
328
+ title: `Add profile: ${author}/${name}`,
329
+ body: buildPRBody(author, name, JSON.parse(profileJson)),
330
+ head: prHead,
331
+ base: 'main'
332
+ }, repo);
333
+
334
+ return pr;
335
+ }
336
+
337
+ /**
338
+ * Fetch the current index.json from the repo.
339
+ */
340
+ export async function fetchRepoIndex(token, repo) {
341
+ const fetch = await getFetch();
342
+
343
+ const response = await fetch(`${GITHUB_API}/repos/${repo}/contents/index.json`, {
344
+ headers: authHeaders(token)
345
+ });
346
+
347
+ if (!response.ok) {
348
+ throw new Error(`Failed to fetch index.json: ${response.status}`);
349
+ }
350
+
351
+ const data = await response.json();
352
+ return JSON.parse(Buffer.from(data.content, 'base64').toString('utf-8'));
353
+ }
354
+
355
+ function buildPRBody(author, name, metadata) {
356
+ const lines = [
357
+ `## Profile Submission`,
358
+ '',
359
+ `Adds profile **${author}/${name}** v${metadata.version || '1.0.0'}`,
360
+ '',
361
+ `**Description:** ${metadata.description || 'No description'}`,
362
+ ''
363
+ ];
364
+
365
+ const contents = metadata.contents || {};
366
+ if (Object.keys(contents).length > 0) {
367
+ lines.push('**Contents:**');
368
+ for (const [cat, items] of Object.entries(contents)) {
369
+ if (items && items.length > 0) {
370
+ const display = cat === 'commands' ? items.map(i => `/${i}`).join(', ') : items.join(', ');
371
+ lines.push(`- ${cat}: ${display}`);
372
+ }
373
+ }
374
+ lines.push('');
375
+ }
376
+
377
+ return lines.join('\n');
378
+ }
379
+
380
+ /**
381
+ * Returns setup instructions when no credentials are found.
382
+ */
383
+ export function getCredentialSetupInstructions() {
384
+ return [
385
+ '',
386
+ 'To set up HTTPS credentials for GitHub:',
387
+ '',
388
+ ' 1. Create a token at: https://github.com/settings/tokens/new',
389
+ ' (select the "public_repo" scope)',
390
+ '',
391
+ ' 2. Store it in your git credential manager:',
392
+ '',
393
+ ' git credential approve <<EOF',
394
+ ' protocol=https',
395
+ ' host=github.com',
396
+ ' username=YOUR_USERNAME',
397
+ ' password=YOUR_TOKEN',
398
+ ' EOF',
399
+ '',
400
+ 'Then re-run: cpm publish <profile-name>',
401
+ ''
402
+ ].join('\n');
403
+ }