@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.md +8 -2
- package/package.json +1 -1
- package/plugins/store-automator/.claude-plugin/plugin.json +1 -1
- package/plugins/store-automator/agents/devops.md +7 -4
- package/src/backup.mjs +99 -0
- package/src/install.mjs +21 -1
- package/src/managed-settings.mjs +88 -0
- package/src/settings.mjs +0 -37
- package/src/templates.mjs +51 -4
- package/src/uninstall.mjs +146 -16
- package/templates/github/workflows/ios-native-release.yml +32 -5
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "App Store & Google Play automation for Flutter apps",
|
|
8
|
-
"version": "0.10.
|
|
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.
|
|
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
|
@@ -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**
|
|
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,
|
|
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
|
|
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
|
|
88
|
+
let body = readFileSync(template, 'utf8');
|
|
57
89
|
if (appName) {
|
|
58
|
-
|
|
90
|
+
body = body.split('{APP_NAME}').join(appName);
|
|
59
91
|
}
|
|
60
|
-
|
|
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 {
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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.
|
|
4
|
-
#
|
|
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:`
|
|
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
|
|
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 }}
|