climaybe 3.4.7 → 3.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -31
- package/bin/version.txt +1 -1
- package/package.json +3 -3
- package/src/commands/add-cursor-skill.js +11 -12
- package/src/commands/add-store.js +15 -0
- package/src/commands/app-init.js +8 -12
- package/src/commands/create-entrypoints.js +13 -3
- package/src/commands/ensure-branches.js +11 -0
- package/src/commands/init.js +114 -17
- package/src/commands/update-workflows.js +3 -2
- package/src/cursor/rules/00-rule-index.mdc +1 -1
- package/src/cursor/rules/cursor-rule-template.mdc +8 -7
- package/src/cursor/rules/figma-design-system.mdc +8 -8
- package/src/cursor/rules/global-rules-reference.mdc +1 -1
- package/src/cursor/rules/snippets.mdc +1 -1
- package/src/cursor/skills/accessibility-pass/SKILL.md +2 -2
- package/src/cursor/skills/changelog-release/SKILL.md +2 -2
- package/src/cursor/skills/commit-in-groups/SKILL.md +2 -2
- package/src/cursor/skills/linear-create-task/SKILL.md +1 -1
- package/src/cursor/skills/liquid-doc-comments/SKILL.md +2 -2
- package/src/cursor/skills/locale-translation-prep/SKILL.md +2 -2
- package/src/cursor/skills/schema-section-change/SKILL.md +3 -3
- package/src/cursor/skills/section-from-spec/SKILL.md +6 -6
- package/src/cursor/skills/theme-check-fix/SKILL.md +4 -4
- package/src/index.js +3 -3
- package/src/lib/branch-protection.js +205 -0
- package/src/lib/config.js +11 -1
- package/src/lib/cursor-bundle.js +150 -12
- package/src/lib/git.js +12 -0
- package/src/lib/prompts.js +98 -4
- package/src/lib/theme-dev-kit.js +4 -0
- package/src/workflows/build/build-pipeline.yml +14 -0
- package/src/workflows/multi/pr-to-live.yml +1 -1
- package/src/workflows/preview/reusable-comment-on-pr.yml +6 -3
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { getGitHubRepoSpec, hasGitHubRemote, isGhAvailable } from './github-secrets.js';
|
|
4
|
+
|
|
5
|
+
export const LIVE_BRANCH_BYPASS_USERS = ['shopify[bot]', 'github-actions[bot]', 'actions-user'];
|
|
6
|
+
|
|
7
|
+
function runGh(args, cwd = process.cwd(), input = null) {
|
|
8
|
+
return spawnSync('gh', args, {
|
|
9
|
+
cwd,
|
|
10
|
+
input: input == null ? undefined : input,
|
|
11
|
+
encoding: 'utf-8',
|
|
12
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function remoteBranchExists(branch, cwd = process.cwd()) {
|
|
17
|
+
try {
|
|
18
|
+
execSync(`git ls-remote --exit-code --heads origin ${branch}`, {
|
|
19
|
+
cwd,
|
|
20
|
+
encoding: 'utf-8',
|
|
21
|
+
stdio: 'pipe',
|
|
22
|
+
});
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isNotFoundError(result) {
|
|
30
|
+
const text = `${result?.stdout || ''} ${result?.stderr || ''}`;
|
|
31
|
+
return /404|not found|branch not found/i.test(text);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getBranchProtectionTargets(mode = 'single', aliases = []) {
|
|
35
|
+
const liveBranches = aliases.map((alias) => `live-${alias}`);
|
|
36
|
+
if (mode === 'multi') {
|
|
37
|
+
return {
|
|
38
|
+
protect: liveBranches,
|
|
39
|
+
unprotect: ['main'],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
protect: ['main'],
|
|
44
|
+
unprotect: liveBranches,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildBranchProtectionPayload({ allowShopifyBypass = false } = {}) {
|
|
49
|
+
return {
|
|
50
|
+
required_status_checks: null,
|
|
51
|
+
enforce_admins: true,
|
|
52
|
+
required_pull_request_reviews: {
|
|
53
|
+
dismiss_stale_reviews: false,
|
|
54
|
+
require_code_owner_reviews: false,
|
|
55
|
+
required_approving_review_count: 0,
|
|
56
|
+
require_last_push_approval: false,
|
|
57
|
+
bypass_pull_request_allowances: {
|
|
58
|
+
users: allowShopifyBypass ? LIVE_BRANCH_BYPASS_USERS : [],
|
|
59
|
+
teams: [],
|
|
60
|
+
apps: [],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
restrictions: null,
|
|
64
|
+
required_linear_history: false,
|
|
65
|
+
allow_force_pushes: false,
|
|
66
|
+
allow_deletions: false,
|
|
67
|
+
block_creations: false,
|
|
68
|
+
required_conversation_resolution: true,
|
|
69
|
+
lock_branch: false,
|
|
70
|
+
allow_fork_syncing: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function protectBranch({ repo, branch, allowShopifyBypass = false, cwd = process.cwd() }) {
|
|
75
|
+
const path = `repos/${repo}/branches/${branch}/protection`;
|
|
76
|
+
const payload = JSON.stringify(buildBranchProtectionPayload({ allowShopifyBypass }));
|
|
77
|
+
const result = runGh(
|
|
78
|
+
[
|
|
79
|
+
'api',
|
|
80
|
+
'--method',
|
|
81
|
+
'PUT',
|
|
82
|
+
'-H',
|
|
83
|
+
'Accept: application/vnd.github+json',
|
|
84
|
+
path,
|
|
85
|
+
'--input',
|
|
86
|
+
'-',
|
|
87
|
+
],
|
|
88
|
+
cwd,
|
|
89
|
+
payload
|
|
90
|
+
);
|
|
91
|
+
if (result.status !== 0) {
|
|
92
|
+
throw new Error((result.stderr || result.stdout || `gh api failed for ${branch}`).trim());
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function unprotectBranch({ repo, branch, cwd = process.cwd() }) {
|
|
97
|
+
const path = `repos/${repo}/branches/${branch}/protection`;
|
|
98
|
+
const result = runGh(
|
|
99
|
+
[
|
|
100
|
+
'api',
|
|
101
|
+
'--method',
|
|
102
|
+
'DELETE',
|
|
103
|
+
'-H',
|
|
104
|
+
'Accept: application/vnd.github+json',
|
|
105
|
+
path,
|
|
106
|
+
],
|
|
107
|
+
cwd
|
|
108
|
+
);
|
|
109
|
+
if (result.status !== 0 && !isNotFoundError(result)) {
|
|
110
|
+
throw new Error((result.stderr || result.stdout || `gh api failed for ${branch}`).trim());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function syncBranchProtection({ mode = 'single', aliases = [], cwd = process.cwd() } = {}) {
|
|
115
|
+
if (!hasGitHubRemote(cwd)) {
|
|
116
|
+
return { skipped: 'no_github_remote', applied: [], removed: [], pending: [], failed: [] };
|
|
117
|
+
}
|
|
118
|
+
if (!isGhAvailable()) {
|
|
119
|
+
return { skipped: 'gh_unavailable', applied: [], removed: [], pending: [], failed: [] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const repo = getGitHubRepoSpec(cwd);
|
|
123
|
+
if (!repo) {
|
|
124
|
+
return { skipped: 'repo_parse_failed', applied: [], removed: [], pending: [], failed: [] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { protect, unprotect } = getBranchProtectionTargets(mode, aliases);
|
|
128
|
+
const applied = [];
|
|
129
|
+
const removed = [];
|
|
130
|
+
const pending = [];
|
|
131
|
+
const failed = [];
|
|
132
|
+
|
|
133
|
+
for (const branch of unprotect) {
|
|
134
|
+
if (!remoteBranchExists(branch, cwd)) continue;
|
|
135
|
+
try {
|
|
136
|
+
unprotectBranch({ repo, branch, cwd });
|
|
137
|
+
removed.push(branch);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
failed.push({ branch, action: 'unprotect', message: err.message });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const branch of protect) {
|
|
144
|
+
if (!remoteBranchExists(branch, cwd)) {
|
|
145
|
+
pending.push(branch);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const isLive = /^live-/.test(branch);
|
|
150
|
+
protectBranch({
|
|
151
|
+
repo,
|
|
152
|
+
branch,
|
|
153
|
+
allowShopifyBypass: isLive,
|
|
154
|
+
cwd,
|
|
155
|
+
});
|
|
156
|
+
applied.push(branch);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
failed.push({ branch, action: 'protect', message: err.message });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { skipped: null, applied, removed, pending, failed };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function logBranchProtectionResult(result, mode = 'single') {
|
|
166
|
+
if (result.skipped === 'no_github_remote') {
|
|
167
|
+
console.log(pc.dim(' Branch protection: skipped (no GitHub origin remote).'));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (result.skipped === 'gh_unavailable') {
|
|
171
|
+
console.log(pc.dim(' Branch protection: skipped (gh CLI unavailable or not authenticated).'));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (result.skipped === 'repo_parse_failed') {
|
|
175
|
+
console.log(pc.dim(' Branch protection: skipped (could not resolve owner/repo from origin).'));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (result.removed.length > 0) {
|
|
180
|
+
console.log(pc.green(` Branch protection removed: ${result.removed.join(', ')}`));
|
|
181
|
+
}
|
|
182
|
+
if (result.applied.length > 0) {
|
|
183
|
+
console.log(pc.green(` Branch protection applied: ${result.applied.join(', ')}`));
|
|
184
|
+
}
|
|
185
|
+
if (result.pending.length > 0) {
|
|
186
|
+
console.log(
|
|
187
|
+
pc.yellow(
|
|
188
|
+
` Branch protection pending for non-remote branch(es): ${result.pending.join(', ')} (push branches, then run "climaybe ensure-branches")`
|
|
189
|
+
)
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (result.failed.length > 0) {
|
|
193
|
+
for (const item of result.failed) {
|
|
194
|
+
console.log(pc.red(` Branch protection ${item.action} failed for ${item.branch}: ${item.message}`));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (
|
|
198
|
+
result.applied.length === 0 &&
|
|
199
|
+
result.removed.length === 0 &&
|
|
200
|
+
result.pending.length === 0 &&
|
|
201
|
+
result.failed.length === 0
|
|
202
|
+
) {
|
|
203
|
+
console.log(pc.dim(` Branch protection: no changes needed (${mode}-store mode).`));
|
|
204
|
+
}
|
|
205
|
+
}
|
package/src/lib/config.js
CHANGED
|
@@ -261,13 +261,23 @@ export function isCommitlintEnabled(cwd = process.cwd()) {
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
/**
|
|
264
|
-
* Whether bundled
|
|
264
|
+
* Whether the bundled AI ruleset (rules, skills, subagents) was installed (init or add-cursor).
|
|
265
265
|
*/
|
|
266
266
|
export function isCursorSkillsEnabled(cwd = process.cwd()) {
|
|
267
267
|
const config = readConfig(cwd);
|
|
268
268
|
return config?.cursor_skills === true;
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Editors bridged to the .config/ai ruleset. Falls back to ['cursor'] for configs
|
|
273
|
+
* written before the multi-editor option existed.
|
|
274
|
+
*/
|
|
275
|
+
export function getAiEditors(cwd = process.cwd()) {
|
|
276
|
+
const config = readConfig(cwd);
|
|
277
|
+
const editors = config?.ai_editors;
|
|
278
|
+
return Array.isArray(editors) && editors.length > 0 ? editors : ['cursor'];
|
|
279
|
+
}
|
|
280
|
+
|
|
271
281
|
/**
|
|
272
282
|
* Add a store entry to the config.
|
|
273
283
|
* Returns the updated config.
|
package/src/lib/cursor-bundle.js
CHANGED
|
@@ -1,14 +1,64 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
statSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
copyFileSync,
|
|
9
|
+
symlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { join, dirname, relative } from 'node:path';
|
|
3
13
|
import { fileURLToPath } from 'node:url';
|
|
4
14
|
|
|
5
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
16
|
|
|
7
|
-
/** Bundled Electric Maybe
|
|
17
|
+
/** Bundled Electric Maybe AI rules, skills, and subagents (shipped under src/cursor/). */
|
|
8
18
|
const BUNDLE_ROOT = join(__dirname, '..', 'cursor');
|
|
9
19
|
|
|
10
20
|
const SKIP_NAMES = new Set(['.DS_Store']);
|
|
11
21
|
|
|
22
|
+
/** Single source of truth for AI/editor config inside the target repo. */
|
|
23
|
+
export const AI_CONFIG_DIR = '.config/ai';
|
|
24
|
+
|
|
25
|
+
/** Canonical entry doc that the flat editor files (AGENTS.md, CLAUDE.md, …) point at. */
|
|
26
|
+
export const AI_RULES_ENTRY = `${AI_CONFIG_DIR}/rules.md`;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Editor "bridges" — each maps an editor's expected path to the shared source of truth.
|
|
30
|
+
* `kind: 'dir'` links a folder (the editor reads rules/skills/agents from it);
|
|
31
|
+
* `kind: 'file'` links a single instructions file to the combined rules entry doc.
|
|
32
|
+
*/
|
|
33
|
+
export const EDITOR_BRIDGES = {
|
|
34
|
+
cursor: { label: 'Cursor', bridges: [{ link: '.cursor', target: AI_CONFIG_DIR, kind: 'dir' }] },
|
|
35
|
+
windsurf: { label: 'Windsurf', bridges: [{ link: '.windsurf', target: AI_CONFIG_DIR, kind: 'dir' }] },
|
|
36
|
+
cline: { label: 'Cline / Roo Code', bridges: [{ link: '.clinerules', target: AI_CONFIG_DIR, kind: 'dir' }] },
|
|
37
|
+
claude: { label: 'Claude Code', bridges: [{ link: 'CLAUDE.md', target: AI_RULES_ENTRY, kind: 'file' }] },
|
|
38
|
+
copilot: {
|
|
39
|
+
label: 'GitHub Copilot / VS Code',
|
|
40
|
+
bridges: [{ link: '.github/copilot-instructions.md', target: AI_RULES_ENTRY, kind: 'file' }],
|
|
41
|
+
},
|
|
42
|
+
agents: { label: 'Other editors (AGENTS.md)', bridges: [{ link: 'AGENTS.md', target: AI_RULES_ENTRY, kind: 'file' }] },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const RULES_ENTRY_CONTENT = `# Electric Maybe — AI ruleset
|
|
46
|
+
|
|
47
|
+
This file is the shared entry point for AI/editor assistants working in this repo.
|
|
48
|
+
The real content lives in \`${AI_CONFIG_DIR}/\`; every editor reads it through a bridge
|
|
49
|
+
file or symlink, so there is a single source of truth and nothing to duplicate.
|
|
50
|
+
|
|
51
|
+
- **Rules:** \`${AI_CONFIG_DIR}/rules/\` — start with \`00-rule-index.mdc\`
|
|
52
|
+
- **Skills:** \`${AI_CONFIG_DIR}/skills/\`
|
|
53
|
+
- **Agents / subagents:** \`${AI_CONFIG_DIR}/agents/\`
|
|
54
|
+
|
|
55
|
+
Edit files under \`${AI_CONFIG_DIR}/\` only. Bridges (\`.cursor\`, \`.windsurf\`,
|
|
56
|
+
\`.clinerules\`, \`AGENTS.md\`, \`CLAUDE.md\`, \`.github/copilot-instructions.md\`) point back
|
|
57
|
+
here, so a change is picked up by every editor at once.
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const isWindows = process.platform === 'win32';
|
|
61
|
+
|
|
12
62
|
/**
|
|
13
63
|
* Recursively copy directory tree; skips junk files (e.g. .DS_Store).
|
|
14
64
|
* @param {string} src
|
|
@@ -29,21 +79,109 @@ function copyTree(src, dest) {
|
|
|
29
79
|
}
|
|
30
80
|
}
|
|
31
81
|
|
|
82
|
+
function removeIfExists(path) {
|
|
83
|
+
try {
|
|
84
|
+
lstatSync(path);
|
|
85
|
+
rmSync(path, { recursive: true, force: true });
|
|
86
|
+
} catch {
|
|
87
|
+
// nothing to remove
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create one editor bridge to the shared source of truth.
|
|
93
|
+
* Prefers a symlink (so there is zero duplication); falls back to copying when the
|
|
94
|
+
* platform refuses symlinks (e.g. Windows without privilege).
|
|
95
|
+
* @returns {{ link: string, mode: 'symlink' | 'copy' }}
|
|
96
|
+
*/
|
|
97
|
+
function createBridge(cwd, { link, target, kind }) {
|
|
98
|
+
const linkPath = join(cwd, link);
|
|
99
|
+
const absTarget = join(cwd, target);
|
|
100
|
+
mkdirSync(dirname(linkPath), { recursive: true });
|
|
101
|
+
removeIfExists(linkPath);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
if (kind === 'dir') {
|
|
105
|
+
// Junctions on Windows need an absolute target and no privilege; POSIX uses a
|
|
106
|
+
// relative symlink so the repo stays portable when moved or cloned.
|
|
107
|
+
const symType = isWindows ? 'junction' : 'dir';
|
|
108
|
+
const symTarget = isWindows ? absTarget : relative(dirname(linkPath), absTarget);
|
|
109
|
+
symlinkSync(symTarget, linkPath, symType);
|
|
110
|
+
} else {
|
|
111
|
+
const symTarget = relative(dirname(linkPath), absTarget);
|
|
112
|
+
symlinkSync(symTarget, linkPath, 'file');
|
|
113
|
+
}
|
|
114
|
+
return { link, mode: 'symlink' };
|
|
115
|
+
} catch {
|
|
116
|
+
// Fallback: copy so the file/folder still exists even without symlink support.
|
|
117
|
+
if (kind === 'dir') {
|
|
118
|
+
copyTree(absTarget, linkPath);
|
|
119
|
+
} else {
|
|
120
|
+
copyFileSync(absTarget, linkPath);
|
|
121
|
+
}
|
|
122
|
+
return { link, mode: 'copy' };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
32
126
|
/**
|
|
33
|
-
* Install
|
|
127
|
+
* Install the Electric Maybe AI bundle into `.config/ai/` (single source of truth) and
|
|
128
|
+
* create bridge files/symlinks for the chosen editors.
|
|
129
|
+
*
|
|
34
130
|
* @param {string} [cwd] - Working directory (default process.cwd())
|
|
35
|
-
* @
|
|
131
|
+
* @param {{ editors?: string[] }} [opts] - Editor keys from EDITOR_BRIDGES (default: ['cursor'])
|
|
132
|
+
* @returns {{ ok: boolean, editors: string[], bridges: Array<{link: string, mode: string}> }}
|
|
36
133
|
*/
|
|
37
|
-
export function
|
|
134
|
+
export function scaffoldAiConfig(cwd = process.cwd(), { editors = ['cursor'] } = {}) {
|
|
38
135
|
const rulesSrc = join(BUNDLE_ROOT, 'rules');
|
|
39
136
|
const skillsSrc = join(BUNDLE_ROOT, 'skills');
|
|
40
137
|
const agentsSrc = join(BUNDLE_ROOT, 'agents');
|
|
41
138
|
if (!existsSync(rulesSrc) || !existsSync(skillsSrc) || !existsSync(agentsSrc)) {
|
|
42
|
-
return false;
|
|
139
|
+
return { ok: false, editors: [], bridges: [] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const aiRoot = join(cwd, AI_CONFIG_DIR);
|
|
143
|
+
copyTree(rulesSrc, join(aiRoot, 'rules'));
|
|
144
|
+
copyTree(skillsSrc, join(aiRoot, 'skills'));
|
|
145
|
+
copyTree(agentsSrc, join(aiRoot, 'agents'));
|
|
146
|
+
writeFileSync(join(cwd, AI_RULES_ENTRY), RULES_ENTRY_CONTENT, 'utf-8');
|
|
147
|
+
|
|
148
|
+
const selected = editors.filter((key) => EDITOR_BRIDGES[key]);
|
|
149
|
+
const bridges = [];
|
|
150
|
+
for (const key of selected) {
|
|
151
|
+
for (const bridge of EDITOR_BRIDGES[key].bridges) {
|
|
152
|
+
bridges.push(createBridge(cwd, bridge));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, editors: selected, bridges };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Pretty-print the outcome of scaffoldAiConfig() with picocolors. Lazily imported so
|
|
160
|
+
* this module stays usable in non-CLI contexts (tests) without color deps.
|
|
161
|
+
* @param {ReturnType<typeof scaffoldAiConfig>} result
|
|
162
|
+
* @param {{ pc: import('picocolors').Picocolors }} deps
|
|
163
|
+
*/
|
|
164
|
+
export function logAiConfigResult(result, { pc }) {
|
|
165
|
+
if (!result.ok) {
|
|
166
|
+
console.log(pc.yellow(' AI ruleset not found in this climaybe install (skipped).'));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
console.log(pc.green(` Electric Maybe AI ruleset → ${AI_CONFIG_DIR}/ (rules, skills, agents)`));
|
|
170
|
+
if (result.bridges.length > 0) {
|
|
171
|
+
const links = result.bridges.map((b) => (b.mode === 'copy' ? `${b.link} (copy)` : b.link));
|
|
172
|
+
console.log(pc.dim(` Editor bridges: ${links.join(', ')}`));
|
|
173
|
+
if (result.bridges.some((b) => b.mode === 'copy')) {
|
|
174
|
+
console.log(pc.dim(' (Some bridges were copied because this platform blocked symlinks.)'));
|
|
175
|
+
}
|
|
43
176
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
177
|
+
console.log(pc.dim(` Edit rules in ${AI_CONFIG_DIR}/ — every bridged editor reads the same files.`));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Back-compat wrapper for callers that only want the Cursor bridge.
|
|
182
|
+
* @param {string} [cwd]
|
|
183
|
+
* @returns {boolean} false if bundle source is missing (broken install)
|
|
184
|
+
*/
|
|
185
|
+
export function scaffoldCursorBundle(cwd = process.cwd()) {
|
|
186
|
+
return scaffoldAiConfig(cwd, { editors: ['cursor'] }).ok;
|
|
49
187
|
}
|
package/src/lib/git.js
CHANGED
|
@@ -119,6 +119,18 @@ export function hasOriginRemote(cwd = process.cwd()) {
|
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Add an `origin` remote from an "owner/repo" slug for the given host ('github' | 'gitlab').
|
|
124
|
+
* No-op (returns the existing URL) if origin already exists. Returns the remote URL on success.
|
|
125
|
+
*/
|
|
126
|
+
export function addOriginRemote(slug, host = 'github', cwd = process.cwd()) {
|
|
127
|
+
if (hasOriginRemote(cwd)) return exec('git remote get-url origin', cwd);
|
|
128
|
+
const base = host === 'gitlab' ? 'https://gitlab.com' : 'https://github.com';
|
|
129
|
+
const url = `${base}/${slug}.git`;
|
|
130
|
+
exec(`git remote add origin ${url}`, cwd);
|
|
131
|
+
return url;
|
|
132
|
+
}
|
|
133
|
+
|
|
122
134
|
/**
|
|
123
135
|
* Push branches to origin if remote exists.
|
|
124
136
|
*/
|
package/src/lib/prompts.js
CHANGED
|
@@ -142,13 +142,28 @@ export async function promptBuildWorkflows() {
|
|
|
142
142
|
const { enableBuildWorkflows } = await prompts({
|
|
143
143
|
type: 'confirm',
|
|
144
144
|
name: 'enableBuildWorkflows',
|
|
145
|
-
message: 'Enable build +
|
|
145
|
+
message: 'Enable build workflows? (bundle _scripts JS + compile Tailwind in CI)',
|
|
146
146
|
initial: true,
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
return !!enableBuildWorkflows;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Ask whether Lighthouse CI should run as part of the build pipeline.
|
|
154
|
+
* Only meaningful when build workflows are enabled (Lighthouse runs after the build).
|
|
155
|
+
*/
|
|
156
|
+
export async function promptLighthouseWorkflows() {
|
|
157
|
+
const { enableLighthouseWorkflows } = await prompts({
|
|
158
|
+
type: 'confirm',
|
|
159
|
+
name: 'enableLighthouseWorkflows',
|
|
160
|
+
message: 'Also run Lighthouse CI on the staging branch? (performance + a11y budget)',
|
|
161
|
+
initial: true,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return !!enableLighthouseWorkflows;
|
|
165
|
+
}
|
|
166
|
+
|
|
152
167
|
/**
|
|
153
168
|
* Ask whether to scaffold local theme dev-kit files (configs, ignores, editor tasks).
|
|
154
169
|
*/
|
|
@@ -193,20 +208,61 @@ export async function promptCommitlint() {
|
|
|
193
208
|
}
|
|
194
209
|
|
|
195
210
|
/**
|
|
196
|
-
* Ask whether to
|
|
211
|
+
* Ask whether to reconcile GitHub branch protection for the configured mode
|
|
212
|
+
* (protect main in single-store, or each live-<alias> in multi-store).
|
|
213
|
+
*/
|
|
214
|
+
export async function promptBranchProtection() {
|
|
215
|
+
const { enableBranchProtection } = await prompts({
|
|
216
|
+
type: 'confirm',
|
|
217
|
+
name: 'enableBranchProtection',
|
|
218
|
+
message:
|
|
219
|
+
'Set up GitHub branch protection? (require PRs on production branches; needs a GitHub origin + authenticated gh CLI)',
|
|
220
|
+
initial: true,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return !!enableBranchProtection;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Ask whether to install the bundled Electric Maybe AI ruleset (rules, skills, subagents)
|
|
228
|
+
* into a single `.config/ai/` source of truth.
|
|
197
229
|
*/
|
|
198
230
|
export async function promptCursorSkills() {
|
|
199
231
|
const { enableCursorSkills } = await prompts({
|
|
200
232
|
type: 'confirm',
|
|
201
233
|
name: 'enableCursorSkills',
|
|
202
|
-
message:
|
|
203
|
-
'Install Electric Maybe Cursor bundle? (rules, skills, subagents e.g. theme-translator in .cursor/)',
|
|
234
|
+
message: 'Install the Electric Maybe AI ruleset? (rules, skills, subagents in .config/ai/)',
|
|
204
235
|
initial: true,
|
|
205
236
|
});
|
|
206
237
|
|
|
207
238
|
return !!enableCursorSkills;
|
|
208
239
|
}
|
|
209
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Ask which editors to bridge to the shared `.config/ai/` ruleset. Returns an array of
|
|
243
|
+
* editor keys understood by EDITOR_BRIDGES in cursor-bundle.js. Defaults to Cursor.
|
|
244
|
+
*/
|
|
245
|
+
export async function promptAiEditors() {
|
|
246
|
+
const { editors } = await prompts({
|
|
247
|
+
type: 'multiselect',
|
|
248
|
+
name: 'editors',
|
|
249
|
+
message: 'Which editors should read the ruleset? (bridged to .config/ai/, no duplication)',
|
|
250
|
+
instructions: false,
|
|
251
|
+
hint: '- space to toggle, enter to confirm',
|
|
252
|
+
choices: [
|
|
253
|
+
{ title: 'Cursor', value: 'cursor', selected: true },
|
|
254
|
+
{ title: 'Claude Code (CLAUDE.md)', value: 'claude', selected: true },
|
|
255
|
+
{ title: 'GitHub Copilot / VS Code', value: 'copilot', selected: false },
|
|
256
|
+
{ title: 'Windsurf', value: 'windsurf', selected: false },
|
|
257
|
+
{ title: 'Cline / Roo Code', value: 'cline', selected: false },
|
|
258
|
+
{ title: 'Other editors (AGENTS.md)', value: 'agents', selected: false },
|
|
259
|
+
],
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// `prompts` returns undefined on cancel / non-TTY; fall back to Cursor.
|
|
263
|
+
return Array.isArray(editors) && editors.length > 0 ? editors : ['cursor'];
|
|
264
|
+
}
|
|
265
|
+
|
|
210
266
|
/**
|
|
211
267
|
* Prompt for package.json name when creating a new package.json.
|
|
212
268
|
*/
|
|
@@ -287,6 +343,44 @@ export async function promptConfigureCISecrets() {
|
|
|
287
343
|
return host ?? 'skip';
|
|
288
344
|
}
|
|
289
345
|
|
|
346
|
+
/**
|
|
347
|
+
* When no GitHub/GitLab origin remote exists, ask how to proceed at the CI secrets step.
|
|
348
|
+
* Returns 'add' (prompt for owner/repo and add origin), or 'skip' (do it later).
|
|
349
|
+
*/
|
|
350
|
+
export async function promptNoRemoteAction(hostName) {
|
|
351
|
+
const { action } = await prompts({
|
|
352
|
+
type: 'select',
|
|
353
|
+
name: 'action',
|
|
354
|
+
message: `CI secrets need a ${hostName} repo, but this folder has no "origin" remote. What now?`,
|
|
355
|
+
choices: [
|
|
356
|
+
{ title: `Add an ${hostName} remote now and continue`, value: 'add' },
|
|
357
|
+
{ title: 'Skip for now (add secrets later in repo settings)', value: 'skip' },
|
|
358
|
+
],
|
|
359
|
+
initial: 0,
|
|
360
|
+
});
|
|
361
|
+
return action ?? 'skip';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Prompt for an owner/repo slug to wire up as the git origin remote.
|
|
366
|
+
* Returns a trimmed "owner/repo" string, or null if skipped/invalid.
|
|
367
|
+
*/
|
|
368
|
+
export async function promptOwnerRepo(hostName) {
|
|
369
|
+
const { slug } = await prompts({
|
|
370
|
+
type: 'text',
|
|
371
|
+
name: 'slug',
|
|
372
|
+
message: `${hostName} repository (owner/repo)`,
|
|
373
|
+
validate: (v) => {
|
|
374
|
+
const s = String(v || '').trim();
|
|
375
|
+
if (!s) return 'Enter owner/repo, or press Esc to skip';
|
|
376
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(s)) return 'Use the form owner/repo (e.g. electricmaybe/climaybe)';
|
|
377
|
+
return true;
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
const s = String(slug || '').trim();
|
|
381
|
+
return /^[^/\s]+\/[^/\s]+$/.test(s) ? s : null;
|
|
382
|
+
}
|
|
383
|
+
|
|
290
384
|
/**
|
|
291
385
|
* Ask whether to update existing CI secrets (when some are already set). Returns true to update, false to skip.
|
|
292
386
|
*/
|
package/src/lib/theme-dev-kit.js
CHANGED
|
@@ -10,6 +10,11 @@ on:
|
|
|
10
10
|
- 'docs/**'
|
|
11
11
|
- '.github/**'
|
|
12
12
|
- '.cursor/**'
|
|
13
|
+
- '.config/**'
|
|
14
|
+
- '.windsurf/**'
|
|
15
|
+
- '.clinerules/**'
|
|
16
|
+
- 'AGENTS.md'
|
|
17
|
+
- 'CLAUDE.md'
|
|
13
18
|
- '.claude/**'
|
|
14
19
|
- '.eser/**'
|
|
15
20
|
- 'README*'
|
|
@@ -43,11 +48,20 @@ jobs:
|
|
|
43
48
|
SHOP_ACCESS_TOKEN: ${{ secrets.SHOP_ACCESS_TOKEN }}
|
|
44
49
|
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
|
|
45
50
|
steps:
|
|
51
|
+
- name: Checkout code
|
|
52
|
+
uses: actions/checkout@v4
|
|
53
|
+
|
|
46
54
|
- name: Check if Lighthouse CI should run
|
|
47
55
|
id: check
|
|
48
56
|
run: |
|
|
49
57
|
SKIP_REASONS=()
|
|
50
58
|
BR="${{ github.ref_name }}"
|
|
59
|
+
# Lighthouse is opt-in via climaybe.config.json (lighthouse_workflows).
|
|
60
|
+
# Default to enabled when the flag is absent (older configs).
|
|
61
|
+
LH_ENABLED=$(node -e "try{process.stdout.write(require('./climaybe.config.json').lighthouse_workflows===false?'false':'true')}catch(e){process.stdout.write('true')}" 2>/dev/null || echo true)
|
|
62
|
+
if [[ "$LH_ENABLED" != "true" ]]; then
|
|
63
|
+
SKIP_REASONS+=("Lighthouse disabled in climaybe.config.json (lighthouse_workflows: false)")
|
|
64
|
+
fi
|
|
51
65
|
if [[ "$BR" == "staging" ]]; then
|
|
52
66
|
:
|
|
53
67
|
else
|
|
@@ -124,7 +124,7 @@ jobs:
|
|
|
124
124
|
if [ -n "$STAGING_THEME_ID" ]; then
|
|
125
125
|
PREVIEW_URL="https://${DOMAIN}?preview_theme_id=${STAGING_THEME_ID}"
|
|
126
126
|
CUSTOMIZE_URL="https://${DOMAIN}/admin/themes/${STAGING_THEME_ID}/editor"
|
|
127
|
-
BODY="${BODY}"$'\n\n'"
|
|
127
|
+
BODY="${BODY}"$'\n\n'"[${ALIAS} preview](${PREVIEW_URL}) · [customize](${CUSTOMIZE_URL})"
|
|
128
128
|
else
|
|
129
129
|
BODY="${BODY}"$'\n\n'"⚠️ Staging theme link not found for branch \`${STAGING}\`."$'\n'"Expected naming: \`${REPO_NAME}/${STAGING}\` (or suffix match \`/${STAGING}\`)."
|
|
130
130
|
fi
|
|
@@ -107,7 +107,7 @@ jobs:
|
|
|
107
107
|
if (host && tid) {
|
|
108
108
|
const previewUrl = `https://${host}?preview_theme_id=${tid}`;
|
|
109
109
|
const customizeUrl = `https://${host}/admin/themes/${tid}/editor`;
|
|
110
|
-
parts.push('',
|
|
110
|
+
parts.push('', `- [${alias} preview](${previewUrl}) · [customize](${customizeUrl})`);
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
} else if (useFragments) {
|
|
@@ -128,8 +128,11 @@ jobs:
|
|
|
128
128
|
previewUrl = `https://${storeDomain}?preview_theme_id=${themeId}`;
|
|
129
129
|
customizeUrl = `https://${storeDomain}/admin/themes/${themeId}/editor`;
|
|
130
130
|
}
|
|
131
|
-
if (
|
|
132
|
-
|
|
131
|
+
if (previewUrl) {
|
|
132
|
+
const links = [`[theme preview](${previewUrl})`];
|
|
133
|
+
if (customizeUrl) links.push(`[customize](${customizeUrl})`);
|
|
134
|
+
parts.push('', links.join(' · '));
|
|
135
|
+
}
|
|
133
136
|
if (shareOutput.trim()) {
|
|
134
137
|
parts.push('', '### Share Output', '```', shareOutput, '```');
|
|
135
138
|
}
|