@daemux/store-automator 0.10.63 → 0.10.65

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.63"
8
+ "version": "0.10.65"
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.63",
15
+ "version": "0.10.65",
16
16
  "keywords": [
17
17
  "flutter",
18
18
  "app-store",
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @daemux/store-automator
2
2
 
3
- Full App Store & Google Play automation for Flutter apps with Claude Code agents.
3
+ Full App Store & Google Play automation for Flutter apps and native iOS (Swift/SwiftUI) apps with Claude Code agents.
4
4
 
5
5
  ## What It Does
6
6
 
@@ -16,7 +16,13 @@ Plus CI/CD templates for GitHub Actions, Fastlane, web pages, and scripts.
16
16
 
17
17
  - [Claude CLI](https://docs.anthropic.com/en/docs/claude-code/overview) installed
18
18
  - Node.js >= 18
19
- - Flutter project
19
+ - A Flutter project **or** a native iOS (Swift/SwiftUI) Xcode project
20
+
21
+ Project-type-specific templates:
22
+ - Flutter iOS/Android: `templates/github/workflows/ios-release.yml` + `android-release.yml`
23
+ - Native iOS: `templates/github/workflows/ios-native-release.yml` (uses the
24
+ `daemux/daemux-plugins/.github/actions/ios-native-testflight` composite
25
+ action — requires only ASC API key secrets, no Fastlane/Match setup)
20
26
 
21
27
  ## Installation
22
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemux/store-automator",
3
- "version": "0.10.63",
3
+ "version": "0.10.65",
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.63",
3
+ "version": "0.10.65",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -18,7 +18,7 @@ hooks:
18
18
 
19
19
  # DevOps Agent
20
20
 
21
- Handles GitHub Actions CI/CD, Firebase deployment, Cloudflare Pages deployment, and Firestore management for Flutter mobile apps.
21
+ Handles GitHub Actions CI/CD, Firebase deployment, Cloudflare Pages deployment, and Firestore management for Flutter mobile apps and for native iOS (Swift/SwiftUI) apps that deploy via the `ios-native-testflight` composite action.
22
22
 
23
23
  ## Parameters (REQUIRED)
24
24
 
@@ -41,7 +41,10 @@ Monitor and manage GitHub Actions CI/CD builds for iOS and Android. NEVER skip o
41
41
 
42
42
  GitHub Actions runs two parallel workflows on push to `main`:
43
43
 
44
- **iOS Release** (`.github/workflows/ios-release.yml`): Upload metadata + screenshots -> Build IPA -> Code signing -> Deploy to App Store Connect -> Sync IAP
44
+ **iOS Release** - two variants depending on project type:
45
+ - Flutter: `.github/workflows/ios-release.yml` — Upload metadata + screenshots -> Build IPA -> Code signing (Fastlane Match) -> Deploy to App Store Connect -> Sync IAP
46
+ - Native iOS: `.github/workflows/ios-native-release.yml` — Run simulator tests (PR + main) -> Archive -> Sign via on-the-fly ASC-API cert/profile -> Upload to TestFlight via `altool` (main only)
47
+
45
48
  **Android Release** (`.github/workflows/android-release.yml`): Upload metadata + screenshots -> Check Google Play readiness -> Build AAB -> Keystore signing -> Deploy to Google Play -> Sync IAP
46
49
 
47
50
  ### Triggering Builds
@@ -70,9 +73,9 @@ gh run watch <run-id>
70
73
 
71
74
  When a build fails:
72
75
  1. Read the full build log: `gh run view <run-id> --log-failed`
73
- 2. Identify the failing step (Flutter analyze, test, build, signing, upload)
76
+ 2. Identify the failing step (Flutter analyze / Swift build, test, signing, upload)
74
77
  3. Categorize the failure:
75
- - **Code issue**: Flutter analyze errors, test failures -> coordinate fix with developer
78
+ - **Code issue**: Flutter analyze errors, Swift build errors, test failures -> coordinate fix with developer
76
79
  - **Signing issue**: Certificate expired, profile mismatch -> check `creds/` and `ci.config.yaml`
77
80
  - **Store issue**: App Store rejection, metadata error -> check `fastlane/metadata/`
78
81
  - **Infrastructure issue**: Timeout, dependency install failure -> retry or adjust config
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');
@@ -162,13 +164,29 @@ async function withReadline(fn) {
162
164
  }
163
165
  }
164
166
 
167
+ function readIfExists(filePath) {
168
+ return existsSync(filePath) ? readFileSync(filePath, 'utf8') : null;
169
+ }
170
+
165
171
  function configureScopedSettings(baseDir, packageDir, appName, scopeLabel) {
166
172
  ensureDir(baseDir);
167
173
  installClaudeMd(join(baseDir, 'CLAUDE.md'), packageDir, appName);
168
174
  console.log(`Configuring ${scopeLabel} settings...`);
169
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
+
170
183
  injectEnvVars(settingsPath);
171
184
  injectStatusLine(settingsPath);
185
+
186
+ const postContent = readIfExists(settingsPath) ?? '';
187
+ backupSnapshot(settingsPath, preContent, postContent);
188
+
189
+ writeManagedSettings(baseDir);
172
190
  }
173
191
 
174
192
  function printGlobalNote() {
@@ -253,6 +271,8 @@ export async function runInstall(scope, isPostinstall = false, cliTokens = {}) {
253
271
  const appName = prompted.appName || (isGlobal ? 'your app' : undefined);
254
272
  configureScopedSettings(baseDir, packageDir, appName, isGlobal ? 'global' : 'project');
255
273
 
274
+ pruneBackups();
275
+
256
276
  printSummary(scope, oldVersion, newVersion);
257
277
  if (isGlobal) {
258
278
  printGlobalNote();
@@ -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.split('{APP_NAME}').join(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,27 +91,134 @@ function removeGitHubWorkflow(projectDir) {
81
91
  }
82
92
  }
83
93
 
84
- export async function runUninstall(scope) {
85
- console.log(`Uninstalling Daemux Store Automator (scope: ${scope})...`);
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;
86
98
 
87
- runClaudeUninstall(scope);
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
+ }
88
104
 
89
- let doneMessage;
90
- if (scope === 'user') {
91
- console.log('Removing marketplace...');
92
- rmSync(MARKETPLACE_DIR, { recursive: true, force: true });
93
- rmSync(CACHE_DIR, { recursive: true, force: true });
94
- unregisterMarketplace();
95
- doneMessage = '\nDone! store-automator uninstalled globally.';
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)`);
96
124
  } else {
97
- 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.';
125
+ writeFileSync(claudeMdPath, spliced, 'utf8');
126
+ console.log(`Removed plugin section from ${scopeLabel} CLAUDE.md`);
98
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
+
210
+ export async function runUninstall(scope) {
211
+ console.log(`Uninstalling Daemux Store Automator (scope: ${scope})...`);
212
+
213
+ runClaudeUninstall(scope);
99
214
 
100
215
  const isGlobal = scope === 'user';
101
216
  const baseDir = isGlobal ? join(homedir(), '.claude') : join(process.cwd(), '.claude');
102
217
  const scopeLabel = isGlobal ? 'global' : 'project';
103
218
 
104
- removeFileIfExists(join(baseDir, 'CLAUDE.md'), `${scopeLabel} CLAUDE.md`);
219
+ const ledger = readManagedSettings(baseDir);
220
+
221
+ cleanClaudeMd(baseDir, scopeLabel);
105
222
 
106
223
  if (!isGlobal) {
107
224
  removeCiTemplates(process.cwd());
@@ -109,8 +226,21 @@ export async function runUninstall(scope) {
109
226
  }
110
227
 
111
228
  console.log(`Cleaning ${scopeLabel} settings...`);
112
- removeEnvVars(join(baseDir, 'settings.json'));
113
- removeStatusLine(join(baseDir, 'settings.json'));
229
+ cleanSettings(baseDir, scopeLabel, ledger);
230
+
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
+ }
114
244
 
115
245
  console.log(doneMessage);
116
246
  }
@@ -1,26 +1,50 @@
1
1
  name: iOS Deploy
2
2
 
3
- # Native-Swift iOS release pipeline. Archives the app and uploads it to
4
- # TestFlight via the App Store Connect API.
3
+ # Native-Swift iOS release pipeline. On PRs and non-main branches, runs the
4
+ # simulator test suite (build-only, no signing). On push to main, runs tests,
5
+ # archives the app, and uploads to TestFlight via the App Store Connect API.
5
6
  #
6
- # Required repository secrets:
7
+ # Required repository secrets (for upload, main branch only):
7
8
  # ASC_KEY_ID App Store Connect API key id (e.g. "5NBDY6YXJ6")
8
9
  # ASC_ISSUER_ID App Store Connect API issuer id (uuid)
9
10
  # ASC_KEY_P8 Full contents of the AuthKey_*.p8 file
10
11
  #
11
- # Edit the `with:` block below to match your project.
12
+ # Edit the `with:` blocks below to match your project.
12
13
 
13
14
  on:
14
15
  push:
15
16
  branches: [main]
17
+ pull_request:
18
+ branches: [main]
16
19
  workflow_dispatch:
17
20
 
18
21
  concurrency:
19
- group: ios-deploy-${{ github.ref }}
22
+ group: ios-${{ github.workflow }}-${{ github.ref }}
20
23
  cancel-in-progress: false
21
24
 
22
25
  jobs:
26
+ # PRs and non-main pushes: build + test only, no signing or upload.
27
+ build-and-test:
28
+ if: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref != 'refs/heads/main') }}
29
+ runs-on: macos-latest
30
+ timeout-minutes: 45
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+
34
+ - uses: daemux/daemux-plugins/.github/actions/ios-native-testflight@main
35
+ with:
36
+ project: MyApp.xcodeproj
37
+ scheme: MyApp
38
+ bundle-id: com.example.myapp
39
+ team-id: ABCDE12345
40
+ app-store-apple-id: "1234567890"
41
+ run-tests: "true"
42
+ archive: "false"
43
+ upload: "false"
44
+
45
+ # Main branch: test + archive + upload to TestFlight.
23
46
  deploy:
47
+ if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
24
48
  runs-on: macos-latest
25
49
  timeout-minutes: 60
26
50
  steps:
@@ -33,6 +57,9 @@ jobs:
33
57
  bundle-id: com.example.myapp
34
58
  team-id: ABCDE12345
35
59
  app-store-apple-id: "1234567890"
60
+ run-tests: "true"
61
+ archive: "true"
62
+ upload: "true"
36
63
  env:
37
64
  ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
38
65
  ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}