@daemux/store-automator 0.10.62 → 0.10.64

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "App Store & Google Play automation for Flutter apps",
8
- "version": "0.10.62"
8
+ "version": "0.10.64"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "store-automator",
13
13
  "source": "./plugins/store-automator",
14
14
  "description": "3 agents for app store publishing: reviewer, meta-creator, media-designer",
15
- "version": "0.10.62",
15
+ "version": "0.10.64",
16
16
  "keywords": [
17
17
  "flutter",
18
18
  "app-store",
package/README.md CHANGED
@@ -20,30 +20,46 @@ Plus CI/CD templates for GitHub Actions, Fastlane, web pages, and scripts.
20
20
 
21
21
  ## Installation
22
22
 
23
+ **Project (default):** Full install into your Flutter project -- agents, CI/CD templates, MCP servers, and interactive setup:
24
+
23
25
  ```bash
24
26
  cd your-flutter-project
25
- npm install @daemux/store-automator@latest
27
+ npx --yes @daemux/store-automator
26
28
  ```
27
29
 
28
- The postinstall script runs an interactive setup with five sections:
30
+ The installer runs an interactive setup with five sections:
29
31
 
30
32
  1. **App Identity** -- App name, bundle ID, package name, SKU, Apple ID
31
- 2. **Credentials** -- Guided steps for App Store Connect API key, Google Play service account, Android keystore, and Match code signing
33
+ 2. **Credentials** -- App Store Connect API key, Google Play service account, Android keystore, Match code signing
32
34
  3. **Store Settings** -- iOS categories/pricing, Android track/rollout, metadata languages
33
35
  4. **Web Settings** -- Domain, colors, company info, legal jurisdiction
34
- 5. **MCP Tokens** -- Stitch and Cloudflare API keys (optional, press Enter to skip)
35
-
36
- All values are written to `ci.config.yaml`. The installer also:
36
+ 5. **MCP Tokens** -- Stitch and Cloudflare API keys (optional)
37
37
 
38
- - Configures `.mcp.json` with MCP servers (Playwright, mobile-mcp, Stitch, Cloudflare)
39
- - Installs `.claude/CLAUDE.md` with your app name and agent configurations
40
- - Copies CI/CD templates (Fastlane, scripts, web pages, GitHub Actions)
41
- - Configures `.claude/settings.json` with required env vars
42
- - Runs post-install guides for GitHub repo setup, secrets, and Firebase
38
+ All values are written to `ci.config.yaml`. The installer also configures `.mcp.json`, installs `.claude/CLAUDE.md`, copies CI/CD templates, and configures `.claude/settings.json`.
43
39
 
44
40
  Re-running the installer reads existing `ci.config.yaml` values as defaults, so you can update individual fields without re-entering everything.
45
41
 
46
- ## After Installation
42
+ **Global (agents only):** Install the plugin into `~/.claude` for use across all projects, without touching the current directory:
43
+
44
+ ```bash
45
+ npx --yes @daemux/store-automator --global
46
+ ```
47
+
48
+ Global install:
49
+
50
+ - Registers the marketplace and installs the plugin at user scope
51
+ - Writes `~/.claude/CLAUDE.md` with agent configurations
52
+ - Adds required env vars and statusLine to `~/.claude/settings.json`
53
+
54
+ Global install does NOT:
55
+
56
+ - Create `ci.config.yaml`, `fastlane/`, `scripts/`, `web/`, `Gemfile`, `.github/workflows/`, or any other project files
57
+ - Write or modify `.mcp.json` in the current directory
58
+ - Run any interactive prompts or post-install guides
59
+
60
+ Use project install for any Flutter app you are actually publishing. Global install is for agent-only access from arbitrary directories.
61
+
62
+ ## After Installation _(project scope only)_
47
63
 
48
64
  1. Add any credential files not configured during the guided setup:
49
65
  - `creds/AuthKey.p8` -- Apple App Store Connect API key
@@ -52,7 +68,7 @@ Re-running the installer reads existing `ci.config.yaml` values as defaults, so
52
68
  2. Verify `ci.config.yaml` has all required values filled in
53
69
  3. Start Claude Code and use the agents
54
70
 
55
- ## Manual Setup
71
+ ## Manual Setup _(project scope only)_
56
72
 
57
73
  If postinstall was skipped (CI environment), run manually:
58
74
 
@@ -240,19 +256,18 @@ web:
240
256
  google_play_url: "" # Filled after first Android publish
241
257
  ```
242
258
 
243
- ## Usage
259
+ ## Uninstall
244
260
 
245
- ### Global Install
261
+ **Project uninstall:** Removes CI templates, `ci.config.yaml`, `.mcp.json` entries, `.claude/CLAUDE.md`, and settings from the current project. Marketplace files remain in `~/.claude/plugins/`.
246
262
 
247
263
  ```bash
248
- npx @daemux/store-automator -g
264
+ npx --yes @daemux/store-automator --uninstall
249
265
  ```
250
266
 
251
- ### Uninstall
267
+ **Global uninstall:** Removes the plugin from `~/.claude` and clears the marketplace. Does NOT touch the current project's `ci.config.yaml`, CI templates, or `.mcp.json`.
252
268
 
253
269
  ```bash
254
- npx @daemux/store-automator -u # project scope
255
- npx @daemux/store-automator -g -u # global scope
270
+ npx --yes @daemux/store-automator --global --uninstall
256
271
  ```
257
272
 
258
273
  ## Agents
@@ -269,7 +284,7 @@ Designs complete app UI screens, creates ASO-optimized store screenshots for all
269
284
 
270
285
  Reviews all metadata, screenshots, privacy policy, and IAP configuration against Apple and Google guidelines. Returns APPROVED or REJECTED with specific issues.
271
286
 
272
- ## CI/CD Templates
287
+ ## CI/CD Templates _(project scope only)_
273
288
 
274
289
  The package installs these templates to your project:
275
290
 
@@ -282,7 +297,7 @@ The package installs these templates to your project:
282
297
  | `web/` | Marketing, privacy, terms, and support page templates |
283
298
  | `Gemfile` | Ruby gems for Fastlane |
284
299
 
285
- ## Workflow
300
+ ## Workflow _(project scope only)_
286
301
 
287
302
  1. Install the package (interactive setup fills `ci.config.yaml`)
288
303
  2. Add any remaining credential files
@@ -291,7 +306,7 @@ The package installs these templates to your project:
291
306
  5. Use `appstore-reviewer` to verify compliance
292
307
  6. Push to GitHub -- GitHub Actions builds and publishes automatically
293
308
 
294
- ## MCP Servers
309
+ ## MCP Servers _(project scope only)_
295
310
 
296
311
  The package configures these MCP servers in `.mcp.json`:
297
312
 
@@ -302,7 +317,7 @@ The package configures these MCP servers in `.mcp.json`:
302
317
  | stitch | AI design tool for screenshot generation | `STITCH_API_KEY` |
303
318
  | cloudflare | Cloudflare Pages deployment | `CLOUDFLARE_API_TOKEN` + Account ID |
304
319
 
305
- ## Idempotency
320
+ ## Idempotency _(project scope only)_
306
321
 
307
322
  The installer is idempotent. Re-running it reads existing values from `ci.config.yaml` as defaults for each prompt. This means you can:
308
323
 
package/bin/cli.mjs CHANGED
@@ -9,8 +9,6 @@ const __filename = fileURLToPath(import.meta.url);
9
9
  const __dirname = dirname(__filename);
10
10
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
11
11
 
12
- const notifier = updateNotifier({ pkg });
13
-
14
12
  const args = process.argv.slice(2);
15
13
  let scope = 'project';
16
14
  let action = 'install';
@@ -146,6 +144,13 @@ Examples:
146
144
  }
147
145
  }
148
146
 
147
+ if (scope === 'user' && cliTokens.githubActions) {
148
+ console.error('Error: --github-actions and --global are mutually exclusive.');
149
+ console.error(' --global targets ~/.claude (no CI templates).');
150
+ console.error(' --github-actions targets a project repo. Pick one.');
151
+ process.exit(1);
152
+ }
153
+
149
154
  if (cliTokens.githubActions) {
150
155
  const missing = [];
151
156
  if (!cliTokens.matchDeployKey) missing.push('--match-deploy-key');
@@ -163,7 +168,9 @@ if (cliTokens.githubActions) {
163
168
  }
164
169
  }
165
170
 
166
- notifier.notify();
171
+ if (!isPostinstall) {
172
+ updateNotifier({ pkg }).notify();
173
+ }
167
174
 
168
175
  try {
169
176
  if (action === 'uninstall') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemux/store-automator",
3
- "version": "0.10.62",
3
+ "version": "0.10.64",
4
4
  "description": "Full App Store & Google Play automation for Flutter apps with Claude Code agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "store-automator",
3
- "version": "0.10.62",
3
+ "version": "0.10.64",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
package/src/backup.mjs ADDED
@@ -0,0 +1,99 @@
1
+ import {
2
+ existsSync, mkdirSync, readFileSync, writeFileSync,
3
+ readdirSync, rmSync, statSync,
4
+ } from 'node:fs';
5
+ import { basename, join } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { createHash } from 'node:crypto';
8
+
9
+ const BACKUP_ROOT = join(homedir(), '.claude', 'backups', 'store-automator');
10
+ const RETAIN_COUNT = 10;
11
+
12
+ function utcTimestamp() {
13
+ return new Date().toISOString().slice(0, 19).replace(/:/g, '');
14
+ }
15
+
16
+ function sha256(buf) {
17
+ return createHash('sha256').update(buf).digest('hex');
18
+ }
19
+
20
+ function writeBackupFile(filePath, contentBuf) {
21
+ const ts = utcTimestamp();
22
+ const dir = join(BACKUP_ROOT, ts);
23
+ const dest = join(dir, `${basename(filePath)}.bak`);
24
+ mkdirSync(dir, { recursive: true });
25
+ writeFileSync(dest, contentBuf);
26
+ console.log(`Backup written to: ${dest}`);
27
+ return dest;
28
+ }
29
+
30
+ export function backupIfChanging(filePath, newContent) {
31
+ if (!existsSync(filePath)) {
32
+ return { backedUp: false, path: null };
33
+ }
34
+
35
+ const current = readFileSync(filePath);
36
+ const incoming = Buffer.isBuffer(newContent) ? newContent : Buffer.from(newContent, 'utf8');
37
+ if (sha256(current) === sha256(incoming)) {
38
+ return { backedUp: false, path: null };
39
+ }
40
+
41
+ const dest = writeBackupFile(filePath, current);
42
+ return { backedUp: true, path: dest };
43
+ }
44
+
45
+ // Snapshot arbitrary content to a timestamped backup. Used for post-hoc backups
46
+ // when the caller only has access to pre-mutation content after the fact
47
+ // (e.g. functions that mutate the target file in place).
48
+ // No-op if preContent already matches postContent (nothing really changed).
49
+ export function backupSnapshot(filePath, preContent, postContent) {
50
+ if (preContent == null) return { backedUp: false, path: null };
51
+ if (preContent === postContent) return { backedUp: false, path: null };
52
+ const buf = Buffer.isBuffer(preContent) ? preContent : Buffer.from(preContent, 'utf8');
53
+ const dest = writeBackupFile(filePath, buf);
54
+ return { backedUp: true, path: dest };
55
+ }
56
+
57
+ function listBackupEntries() {
58
+ if (!existsSync(BACKUP_ROOT)) return [];
59
+ const entries = [];
60
+ for (const tsDir of readdirSync(BACKUP_ROOT)) {
61
+ const tsPath = join(BACKUP_ROOT, tsDir);
62
+ let s;
63
+ try { s = statSync(tsPath); } catch { continue; }
64
+ if (!s.isDirectory()) continue;
65
+ for (const file of readdirSync(tsPath)) {
66
+ const fullPath = join(tsPath, file);
67
+ entries.push({ basename: file, ts: tsDir, fullPath, tsDir: tsPath });
68
+ }
69
+ }
70
+ return entries;
71
+ }
72
+
73
+ export function pruneBackups() {
74
+ const entries = listBackupEntries();
75
+ if (entries.length === 0) return;
76
+
77
+ const byBasename = new Map();
78
+ for (const e of entries) {
79
+ if (!byBasename.has(e.basename)) byBasename.set(e.basename, []);
80
+ byBasename.get(e.basename).push(e);
81
+ }
82
+
83
+ const toDelete = [];
84
+ for (const group of byBasename.values()) {
85
+ group.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));
86
+ toDelete.push(...group.slice(RETAIN_COUNT));
87
+ }
88
+
89
+ for (const e of toDelete) {
90
+ try { rmSync(e.fullPath, { force: true }); } catch { /* ignore */ }
91
+ }
92
+
93
+ const touchedTsDirs = new Set(toDelete.map((e) => e.tsDir));
94
+ for (const tsDir of touchedTsDirs) {
95
+ try {
96
+ if (readdirSync(tsDir).length === 0) rmSync(tsDir, { recursive: true, force: true });
97
+ } catch { /* ignore */ }
98
+ }
99
+ }
package/src/install.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, rmSync, cpSync } from 'node:fs';
1
+ import { existsSync, readFileSync, rmSync, cpSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { execSync } from 'node:child_process';
@@ -13,6 +13,8 @@ import { getMcpServers, writeMcpJson } from './mcp-setup.mjs';
13
13
  import { installClaudeMd, installCiTemplates, installFirebaseTemplates } from './templates.mjs';
14
14
  import { readCiConfig, writeCiFields, writeCiLanguages, writeMatchConfig, isPlaceholder } from './ci-config.mjs';
15
15
  import { installGitHubActionsPath } from './install-paths.mjs';
16
+ import { backupSnapshot, pruneBackups } from './backup.mjs';
17
+ import { writeManagedSettings } from './managed-settings.mjs';
16
18
 
17
19
  function checkClaudeCli() {
18
20
  const result = exec('command -v claude') || exec('which claude');
@@ -152,7 +154,55 @@ function printNextSteps(prompted) {
152
154
  }
153
155
  }
154
156
 
157
+ async function withReadline(fn) {
158
+ const { createInterface } = await import('node:readline');
159
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
160
+ try {
161
+ return await fn(rl);
162
+ } finally {
163
+ rl.close();
164
+ }
165
+ }
166
+
167
+ function readIfExists(filePath) {
168
+ return existsSync(filePath) ? readFileSync(filePath, 'utf8') : null;
169
+ }
170
+
171
+ function configureScopedSettings(baseDir, packageDir, appName, scopeLabel) {
172
+ ensureDir(baseDir);
173
+ installClaudeMd(join(baseDir, 'CLAUDE.md'), packageDir, appName);
174
+ console.log(`Configuring ${scopeLabel} settings...`);
175
+ const settingsPath = join(baseDir, 'settings.json');
176
+
177
+ // Snapshot pre-mutation state so we can back it up if inject* changes anything.
178
+ // inject* functions mutate in place (ensureFile creates {} when missing), so the
179
+ // backup must capture state before they run and be written only if a real change
180
+ // occurred — otherwise re-running install would churn backups on every invocation.
181
+ const preContent = readIfExists(settingsPath);
182
+
183
+ injectEnvVars(settingsPath);
184
+ injectStatusLine(settingsPath);
185
+
186
+ const postContent = readIfExists(settingsPath) ?? '';
187
+ backupSnapshot(settingsPath, preContent, postContent);
188
+
189
+ writeManagedSettings(baseDir);
190
+ }
191
+
192
+ function printGlobalNote() {
193
+ console.log('');
194
+ console.log('Note: Global install registers the marketplace and installs ~/.claude/CLAUDE.md only.');
195
+ console.log('To generate CI/CD templates, MCP config, and ci.config.yaml, run `npx @daemux/store-automator` inside a project directory.');
196
+ }
197
+
155
198
  export async function runInstall(scope, isPostinstall = false, cliTokens = {}) {
199
+ const isGlobal = scope === 'user';
200
+
201
+ if (isPostinstall && isGlobal) {
202
+ console.log('Skipping postinstall in global scope.');
203
+ return;
204
+ }
205
+
156
206
  checkClaudeCli();
157
207
 
158
208
  console.log('Installing/updating Daemux Store Automator...');
@@ -160,102 +210,73 @@ export async function runInstall(scope, isPostinstall = false, cliTokens = {}) {
160
210
  const isGitHubActions = Boolean(cliTokens.githubActions);
161
211
  const nonInteractive = Boolean(process.env.npm_config_yes) || process.argv.includes('--postinstall');
162
212
  const projectDir = process.cwd();
163
- const oldVersion = readMarketplaceVersion();
164
213
  const packageDir = getPackageDir();
214
+ const oldVersion = readMarketplaceVersion();
165
215
 
166
- // 1. Copy plugin files + register marketplace
167
216
  copyPluginFiles(packageDir);
168
217
  clearCache();
169
218
  registerMarketplace();
170
219
  runClaudeInstall(scope);
171
-
172
220
  const newVersion = readMarketplaceVersion('unknown');
173
221
 
174
- // 2. Install CI templates (creates ci.config.yaml if missing)
175
- installCiTemplates(projectDir, packageDir);
176
- installFirebaseTemplates(projectDir, packageDir);
177
-
178
- // 3. Read current ci.config.yaml values
179
- const currentConfig = readCiConfig(projectDir);
180
-
181
- // 4. Run interactive prompts (or use CLI flags / skip in non-interactive)
182
- let prompted;
183
- if (isGitHubActions) {
184
- prompted = {
185
- bundleId: cliTokens.bundleId ?? '',
186
- matchDeployKeyPath: cliTokens.matchDeployKey,
187
- matchGitUrl: cliTokens.matchGitUrl,
188
- };
189
- } else if (nonInteractive) {
190
- prompted = { ...cliTokens };
191
- } else {
192
- const { createInterface } = await import('node:readline');
193
- const rl = createInterface({ input: process.stdin, output: process.stdout });
194
- try {
195
- prompted = await promptAll(rl, cliTokens, currentConfig, projectDir);
196
- } finally {
197
- rl.close();
198
- }
199
- }
200
-
201
- // 5. Write all prompted values to ci.config.yaml
202
- const ciFields = mapPromptsToCiFields(prompted);
203
- const wrote = writeCiFields(projectDir, ciFields);
204
- if (wrote) console.log('Configuration written to ci.config.yaml');
205
-
206
- if (prompted.matchDeployKeyPath || prompted.matchGitUrl) {
207
- const wroteMatch = writeMatchConfig(projectDir, {
208
- deployKeyPath: prompted.matchDeployKeyPath,
209
- gitUrl: prompted.matchGitUrl,
210
- });
211
- if (wroteMatch) console.log('Match credentials written to ci.config.yaml');
212
- }
222
+ let prompted = {};
223
+ if (!isGlobal) {
224
+ installCiTemplates(projectDir, packageDir);
225
+ installFirebaseTemplates(projectDir, packageDir);
226
+ const currentConfig = readCiConfig(projectDir);
213
227
 
214
- // 6. Handle languages separately
215
- if (prompted.languages) {
216
- const langStr = Array.isArray(prompted.languages)
217
- ? prompted.languages.join(',')
218
- : prompted.languages;
219
- if (writeCiLanguages(projectDir, langStr)) {
220
- console.log('Languages updated in ci.config.yaml');
228
+ if (isGitHubActions) {
229
+ prompted = {
230
+ bundleId: cliTokens.bundleId ?? '',
231
+ matchDeployKeyPath: cliTokens.matchDeployKey,
232
+ matchGitUrl: cliTokens.matchGitUrl,
233
+ };
234
+ } else if (nonInteractive) {
235
+ prompted = { ...cliTokens };
236
+ } else {
237
+ prompted = await withReadline((rl) => promptAll(rl, cliTokens, currentConfig, projectDir));
221
238
  }
222
- }
223
-
224
- // 7. Configure MCP, CLAUDE.md, settings
225
- if (!isGitHubActions) {
226
- const servers = getMcpServers(prompted);
227
- writeMcpJson(projectDir, servers);
228
- }
229
-
230
- const baseDir = scope === 'user'
231
- ? join(homedir(), '.claude')
232
- : join(process.cwd(), '.claude');
233
239
 
234
- ensureDir(baseDir);
240
+ if (writeCiFields(projectDir, mapPromptsToCiFields(prompted))) {
241
+ console.log('Configuration written to ci.config.yaml');
242
+ }
235
243
 
236
- installClaudeMd(join(baseDir, 'CLAUDE.md'), packageDir, prompted.appName);
244
+ if (prompted.matchDeployKeyPath || prompted.matchGitUrl) {
245
+ const wroteMatch = writeMatchConfig(projectDir, {
246
+ deployKeyPath: prompted.matchDeployKeyPath,
247
+ gitUrl: prompted.matchGitUrl,
248
+ });
249
+ if (wroteMatch) console.log('Match credentials written to ci.config.yaml');
250
+ }
237
251
 
238
- installGitHubActionsPath(projectDir, packageDir, prompted);
252
+ if (prompted.languages) {
253
+ const langStr = Array.isArray(prompted.languages) ? prompted.languages.join(',') : prompted.languages;
254
+ if (writeCiLanguages(projectDir, langStr)) {
255
+ console.log('Languages updated in ci.config.yaml');
256
+ }
257
+ }
239
258
 
240
- const scopeLabel = scope === 'user' ? 'global' : 'project';
241
- console.log(`Configuring ${scopeLabel} settings...`);
242
- const settingsPath = join(baseDir, 'settings.json');
243
- injectEnvVars(settingsPath);
244
- injectStatusLine(settingsPath);
259
+ if (!isGitHubActions) writeMcpJson(projectDir, getMcpServers(prompted));
260
+ installGitHubActionsPath(projectDir, packageDir, prompted);
245
261
 
246
- // 8. Run post-install guides (interactive only)
247
- if (!isGitHubActions && !nonInteractive) {
248
- const { createInterface } = await import('node:readline');
249
- const guideRl = createInterface({ input: process.stdin, output: process.stdout });
250
- try {
251
- const { runPostInstallGuides } = await import('./prompts/store-settings.mjs');
252
- await runPostInstallGuides(guideRl, currentConfig);
253
- } finally {
254
- guideRl.close();
262
+ if (!isGitHubActions && !nonInteractive) {
263
+ await withReadline(async (rl) => {
264
+ const { runPostInstallGuides } = await import('./prompts/store-settings.mjs');
265
+ await runPostInstallGuides(rl, currentConfig);
266
+ });
255
267
  }
256
268
  }
257
269
 
258
- // 9. Summary + dynamic next steps
270
+ const baseDir = isGlobal ? join(homedir(), '.claude') : join(projectDir, '.claude');
271
+ const appName = prompted.appName || (isGlobal ? 'your app' : undefined);
272
+ configureScopedSettings(baseDir, packageDir, appName, isGlobal ? 'global' : 'project');
273
+
274
+ pruneBackups();
275
+
259
276
  printSummary(scope, oldVersion, newVersion);
260
- printNextSteps(prompted);
277
+ if (isGlobal) {
278
+ printGlobalNote();
279
+ } else {
280
+ printNextSteps(prompted);
281
+ }
261
282
  }
@@ -0,0 +1,88 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ const LEDGER_FILENAME = 'store-automator-managed-settings.json';
5
+
6
+ export const HISTORICAL_ENV_VALUES = {
7
+ 'CLAUDE_CODE_ENABLE_TASKS': 'true',
8
+ 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS': '1',
9
+ };
10
+
11
+ export const HISTORICAL_ENV_KEYS = Object.keys(HISTORICAL_ENV_VALUES);
12
+
13
+ // Ledger is scoped to the settings.json baseDir (e.g. ~/.claude for global or
14
+ // <project>/.claude for project). Keeping it adjacent to the settings file it
15
+ // describes prevents global/project scope overlap.
16
+ export function getLedgerPath(baseDir) {
17
+ return join(baseDir, LEDGER_FILENAME);
18
+ }
19
+
20
+ function getSettingsPath(baseDir) {
21
+ return join(baseDir, 'settings.json');
22
+ }
23
+
24
+ function sortedStringify(value) {
25
+ return JSON.stringify(value, (_, v) => {
26
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
27
+ const out = {};
28
+ for (const key of Object.keys(v).sort()) out[key] = v[key];
29
+ return out;
30
+ }
31
+ return v;
32
+ }, 2) + '\n';
33
+ }
34
+
35
+ function readSettings(settingsPath) {
36
+ if (!existsSync(settingsPath)) return {};
37
+ try {
38
+ return JSON.parse(readFileSync(settingsPath, 'utf8'));
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ export function writeManagedSettings(baseDir) {
45
+ const settingsPath = getSettingsPath(baseDir);
46
+ const ledgerPath = getLedgerPath(baseDir);
47
+ const settings = readSettings(settingsPath);
48
+ const env = settings.env || {};
49
+
50
+ const envKeys = HISTORICAL_ENV_KEYS
51
+ .filter((k) => Object.prototype.hasOwnProperty.call(env, k))
52
+ .sort();
53
+
54
+ const envValues = {};
55
+ for (const k of envKeys) envValues[k] = env[k];
56
+
57
+ const statusLineValue = settings.statusLine;
58
+ const hasExpectedStatusLine = Boolean(
59
+ statusLineValue
60
+ && typeof statusLineValue === 'object'
61
+ && statusLineValue.type === 'command'
62
+ && typeof statusLineValue.command === 'string'
63
+ && statusLineValue.command.includes('context_window'),
64
+ );
65
+
66
+ const ledger = {
67
+ schemaVersion: 1,
68
+ plugin: 'daemux-store-automator',
69
+ envKeys,
70
+ envValues,
71
+ statusLine: hasExpectedStatusLine
72
+ ? { wasSet: true, content: statusLineValue }
73
+ : { wasSet: false, content: null },
74
+ };
75
+
76
+ mkdirSync(dirname(ledgerPath), { recursive: true });
77
+ writeFileSync(ledgerPath, sortedStringify(ledger), 'utf8');
78
+ }
79
+
80
+ export function readManagedSettings(baseDir) {
81
+ const ledgerPath = getLedgerPath(baseDir);
82
+ if (!existsSync(ledgerPath)) return null;
83
+ try {
84
+ return JSON.parse(readFileSync(ledgerPath, 'utf8'));
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
package/src/settings.mjs CHANGED
@@ -1,4 +1,3 @@
1
- import { existsSync } from 'node:fs';
2
1
  import { ensureFile, readJson, writeJson } from './utils.mjs';
3
2
 
4
3
  const ENV_DEFAULTS = {
@@ -47,28 +46,6 @@ export function injectEnvVars(settingsPath) {
47
46
  }
48
47
  }
49
48
 
50
- export function removeEnvVars(settingsPath) {
51
- if (!existsSync(settingsPath)) return;
52
-
53
- try {
54
- const settings = readJson(settingsPath);
55
- if (!settings.env) return;
56
-
57
- for (const key of Object.keys(ENV_DEFAULTS)) {
58
- delete settings.env[key];
59
- }
60
-
61
- if (Object.keys(settings.env).length === 0) {
62
- delete settings.env;
63
- }
64
-
65
- writeJson(settingsPath, settings);
66
- console.log('Removed env vars');
67
- } catch {
68
- // Silently skip if settings file is invalid
69
- }
70
- }
71
-
72
49
  export function injectStatusLine(settingsPath) {
73
50
  try {
74
51
  ensureFile(settingsPath);
@@ -90,17 +67,3 @@ export function injectStatusLine(settingsPath) {
90
67
  }
91
68
  }
92
69
 
93
- export function removeStatusLine(settingsPath) {
94
- if (!existsSync(settingsPath)) return;
95
-
96
- try {
97
- const settings = readJson(settingsPath);
98
- if (!settings.statusLine) return;
99
-
100
- delete settings.statusLine;
101
- writeJson(settingsPath, settings);
102
- console.log('Removed statusLine configuration');
103
- } catch {
104
- // Silently skip if settings file is invalid
105
- }
106
- }
package/src/templates.mjs CHANGED
@@ -1,6 +1,11 @@
1
1
  import { existsSync, cpSync, copyFileSync, chmodSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { ensureDir } from './utils.mjs';
4
+ import { backupIfChanging } from './backup.mjs';
5
+
6
+ const SENTINEL_BEGIN = '<!-- BEGIN daemux-store-automator -->';
7
+ const SENTINEL_END = '<!-- END daemux-store-automator -->';
8
+ const SENTINEL_BLOCK_RE = /<!-- BEGIN daemux-store-automator -->[\s\S]*?<!-- END daemux-store-automator -->/;
4
9
 
5
10
  const FILE_COPIES = [
6
11
  ['ci.config.yaml.template', 'ci.config.yaml'],
@@ -46,18 +51,60 @@ function copyIfMissing(srcPath, destPath, label, isDirectory) {
46
51
  }
47
52
  }
48
53
 
54
+ function wrapInSentinels(body) {
55
+ const trimmed = body.replace(/\s+$/, '');
56
+ return `${SENTINEL_BEGIN}\n${trimmed}\n${SENTINEL_END}\n`;
57
+ }
58
+
59
+ function looksLikeUnsentineledPluginContent(existing) {
60
+ if (!existing) return false;
61
+ const startsWithHeader = /^#\s.+-\s*Development Standards\b/m.test(existing.split('\n').slice(0, 5).join('\n'));
62
+ const hasFastlane = existing.includes('fastlane');
63
+ return startsWithHeader && hasFastlane;
64
+ }
65
+
66
+ function composeNewFileContent(existing, wrappedBlock) {
67
+ if (!existing) {
68
+ return wrappedBlock;
69
+ }
70
+ if (SENTINEL_BLOCK_RE.test(existing)) {
71
+ return existing.replace(SENTINEL_BLOCK_RE, wrappedBlock.replace(/\n$/, ''));
72
+ }
73
+ if (looksLikeUnsentineledPluginContent(existing)) {
74
+ return wrappedBlock;
75
+ }
76
+ const sep = existing.endsWith('\n') ? '' : '\n';
77
+ return `${existing}${sep}\n${wrappedBlock}`;
78
+ }
79
+
49
80
  export function installClaudeMd(targetPath, packageDir, appName) {
50
81
  const template = join(packageDir, 'templates', 'CLAUDE.md.template');
51
82
  if (!existsSync(template)) return;
52
- const action = existsSync(targetPath) ? 'Updating' : 'Installing';
83
+ const targetExists = existsSync(targetPath);
84
+ const action = targetExists ? 'Updating' : 'Installing';
53
85
  console.log(`${action} CLAUDE.md...`);
54
86
  ensureDir(join(targetPath, '..'));
55
87
 
56
- let content = readFileSync(template, 'utf8');
88
+ let body = readFileSync(template, 'utf8');
57
89
  if (appName) {
58
- content = content.replace(/\{APP_NAME\}/g, appName);
90
+ body = body.split('{APP_NAME}').join(appName);
59
91
  }
60
- writeFileSync(targetPath, content, 'utf8');
92
+ const wrappedBlock = wrapInSentinels(body);
93
+
94
+ const existing = targetExists ? readFileSync(targetPath, 'utf8') : '';
95
+ const migrating = targetExists
96
+ && !SENTINEL_BLOCK_RE.test(existing)
97
+ && looksLikeUnsentineledPluginContent(existing);
98
+
99
+ const newContent = composeNewFileContent(existing, wrappedBlock);
100
+
101
+ const backupResult = backupIfChanging(targetPath, newContent);
102
+ if (migrating) {
103
+ const backupPath = backupResult.path || '(no backup needed — content identical)';
104
+ console.log(`Migrating unsentineled plugin content to sentinel-wrapped form (backup: ${backupPath})`);
105
+ }
106
+
107
+ writeFileSync(targetPath, newContent, 'utf8');
61
108
  }
62
109
 
63
110
  export function installCiTemplates(projectDir, packageDir) {
package/src/uninstall.mjs CHANGED
@@ -1,4 +1,7 @@
1
- import { existsSync, rmSync, unlinkSync, readdirSync } from 'node:fs';
1
+ import {
2
+ existsSync, rmSync, unlinkSync, readdirSync,
3
+ readFileSync, writeFileSync,
4
+ } from 'node:fs';
2
5
  import { join } from 'node:path';
3
6
  import { homedir } from 'node:os';
4
7
  import { execSync } from 'node:child_process';
@@ -7,8 +10,15 @@ import {
7
10
  MARKETPLACE_NAME, PLUGIN_REF,
8
11
  readJson, writeJson,
9
12
  } from './utils.mjs';
10
- import { removeEnvVars, removeStatusLine } from './settings.mjs';
11
13
  import { removeMcpServers } from './mcp-setup.mjs';
14
+ import { backupIfChanging, pruneBackups } from './backup.mjs';
15
+ import {
16
+ readManagedSettings, getLedgerPath,
17
+ HISTORICAL_ENV_KEYS, HISTORICAL_ENV_VALUES,
18
+ } from './managed-settings.mjs';
19
+
20
+ const SENTINEL_BEGIN = '<!-- BEGIN daemux-store-automator -->';
21
+ const SENTINEL_END = '<!-- END daemux-store-automator -->';
12
22
 
13
23
  function runClaudeUninstall(scope) {
14
24
  const scopeArg = scope === 'user' ? '' : ` --scope ${scope}`;
@@ -81,32 +91,156 @@ function removeGitHubWorkflow(projectDir) {
81
91
  }
82
92
  }
83
93
 
94
+ function spliceSentinelBlock(content) {
95
+ const beginIdx = content.indexOf(SENTINEL_BEGIN);
96
+ const endIdx = content.indexOf(SENTINEL_END);
97
+ if (beginIdx === -1 || endIdx === -1 || endIdx <= beginIdx) return null;
98
+
99
+ const before = content.slice(0, beginIdx);
100
+ let after = content.slice(endIdx + SENTINEL_END.length);
101
+ if (after.startsWith('\n')) after = after.slice(1);
102
+ return before.replace(/[ \t]+$/, '') + after;
103
+ }
104
+
105
+ function cleanClaudeMd(baseDir, scopeLabel) {
106
+ const claudeMdPath = join(baseDir, 'CLAUDE.md');
107
+ if (!existsSync(claudeMdPath)) return;
108
+
109
+ const content = readFileSync(claudeMdPath, 'utf8');
110
+ const spliced = spliceSentinelBlock(content);
111
+
112
+ if (spliced === null) {
113
+ console.log(
114
+ `Warning: ${scopeLabel} CLAUDE.md has no plugin sentinels; leaving untouched. ` +
115
+ `(If this file contains plugin content from an older version, remove the plugin section manually.)`
116
+ );
117
+ return;
118
+ }
119
+
120
+ backupIfChanging(claudeMdPath, spliced);
121
+ if (spliced.trim() === '') {
122
+ rmSync(claudeMdPath);
123
+ console.log(`Removed ${scopeLabel} CLAUDE.md (contained only plugin content)`);
124
+ } else {
125
+ writeFileSync(claudeMdPath, spliced, 'utf8');
126
+ console.log(`Removed plugin section from ${scopeLabel} CLAUDE.md`);
127
+ }
128
+ }
129
+
130
+ function looksLikeHistoricalPluginStatusLine(current) {
131
+ return Boolean(
132
+ current
133
+ && typeof current === 'object'
134
+ && current.type === 'command'
135
+ && typeof current.command === 'string'
136
+ && current.command.includes('context_window')
137
+ );
138
+ }
139
+
140
+ function pruneManagedEnv(settings, envKeys, envValues) {
141
+ if (!settings.env) return false;
142
+ let modified = false;
143
+ for (const key of envKeys) {
144
+ if (!(key in settings.env)) continue;
145
+ const expected = envValues ? envValues[key] : undefined;
146
+ if (expected === undefined || settings.env[key] === expected) {
147
+ delete settings.env[key];
148
+ modified = true;
149
+ } else {
150
+ console.log(`Preserving user-modified env.${key} in settings.json`);
151
+ }
152
+ }
153
+ if (Object.keys(settings.env).length === 0) delete settings.env;
154
+ return modified;
155
+ }
156
+
157
+ function pruneManagedStatusLine(settings, ledgerStatusLine) {
158
+ if (!settings.statusLine) return false;
159
+
160
+ if (ledgerStatusLine) {
161
+ if (ledgerStatusLine.wasSet !== true) return false;
162
+ const managed = ledgerStatusLine.content;
163
+ if (JSON.stringify(settings.statusLine) !== JSON.stringify(managed)) {
164
+ console.log('Preserving user-modified statusLine in settings.json');
165
+ return false;
166
+ }
167
+ delete settings.statusLine;
168
+ return true;
169
+ }
170
+
171
+ if (!looksLikeHistoricalPluginStatusLine(settings.statusLine)) {
172
+ console.log('Preserving non-plugin statusLine in settings.json (no ledger, signature mismatch)');
173
+ return false;
174
+ }
175
+ delete settings.statusLine;
176
+ return true;
177
+ }
178
+
179
+ function cleanSettings(baseDir, scopeLabel, ledger) {
180
+ const settingsPath = join(baseDir, 'settings.json');
181
+ if (!existsSync(settingsPath)) return;
182
+
183
+ let settings;
184
+ try {
185
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
186
+ } catch {
187
+ return;
188
+ }
189
+
190
+ const envKeys = ledger?.envKeys ?? HISTORICAL_ENV_KEYS;
191
+ const envValues = ledger?.envValues ?? HISTORICAL_ENV_VALUES;
192
+ const envChanged = pruneManagedEnv(settings, envKeys, envValues);
193
+ const statusLineChanged = pruneManagedStatusLine(settings, ledger?.statusLine);
194
+
195
+ if (!envChanged && !statusLineChanged) return;
196
+
197
+ const newRaw = JSON.stringify(settings, null, 2) + '\n';
198
+ backupIfChanging(settingsPath, newRaw);
199
+ writeFileSync(settingsPath, newRaw, 'utf8');
200
+ console.log(`Cleaned ${scopeLabel} settings`);
201
+ }
202
+
203
+ function removeLedger(baseDir) {
204
+ const ledgerPath = getLedgerPath(baseDir);
205
+ if (existsSync(ledgerPath)) {
206
+ try { rmSync(ledgerPath); } catch { /* best-effort */ }
207
+ }
208
+ }
209
+
84
210
  export async function runUninstall(scope) {
85
211
  console.log(`Uninstalling Daemux Store Automator (scope: ${scope})...`);
86
212
 
87
213
  runClaudeUninstall(scope);
88
214
 
89
- if (scope === 'user') {
90
- console.log('Removing marketplace...');
91
- rmSync(MARKETPLACE_DIR, { recursive: true, force: true });
92
- rmSync(CACHE_DIR, { recursive: true, force: true });
93
- unregisterMarketplace();
94
- }
95
-
96
215
  const isGlobal = scope === 'user';
97
216
  const baseDir = isGlobal ? join(homedir(), '.claude') : join(process.cwd(), '.claude');
98
217
  const scopeLabel = isGlobal ? 'global' : 'project';
99
218
 
100
- removeFileIfExists(join(baseDir, 'CLAUDE.md'), `${scopeLabel} CLAUDE.md`);
101
- removeCiTemplates(process.cwd());
102
- removeMcpServers(process.cwd());
219
+ const ledger = readManagedSettings(baseDir);
220
+
221
+ cleanClaudeMd(baseDir, scopeLabel);
222
+
223
+ if (!isGlobal) {
224
+ removeCiTemplates(process.cwd());
225
+ removeMcpServers(process.cwd());
226
+ }
103
227
 
104
228
  console.log(`Cleaning ${scopeLabel} settings...`);
105
- removeEnvVars(join(baseDir, 'settings.json'));
106
- removeStatusLine(join(baseDir, 'settings.json'));
229
+ cleanSettings(baseDir, scopeLabel, ledger);
107
230
 
108
- console.log(isGlobal
109
- ? '\nDone! store-automator uninstalled globally.'
110
- : `\nDone! store-automator uninstalled from this project.\n\nNote: Marketplace files remain in ${MARKETPLACE_DIR}\nRun with --global --uninstall to remove marketplace completely.`
111
- );
231
+ removeLedger(baseDir);
232
+ pruneBackups();
233
+
234
+ let doneMessage;
235
+ if (isGlobal) {
236
+ console.log('Removing marketplace...');
237
+ rmSync(MARKETPLACE_DIR, { recursive: true, force: true });
238
+ rmSync(CACHE_DIR, { recursive: true, force: true });
239
+ unregisterMarketplace();
240
+ doneMessage = '\nDone! store-automator uninstalled globally.';
241
+ } else {
242
+ doneMessage = '\nDone! store-automator uninstalled from this project.\n\nNote: Marketplace files remain under ~/.claude/plugins/marketplaces.\nRun with --global --uninstall to remove marketplace completely.';
243
+ }
244
+
245
+ console.log(doneMessage);
112
246
  }