ppcos 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * One-time client manifest migrations.
3
+ *
4
+ * Each migration is idempotent — safe to run on every update.
5
+ * Add new migrations here and call them from runAllMigrations().
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { isConfigFile } from './manifest.js';
11
+ import { ensureDir } from './fs-helpers.js';
12
+ import logger from './logger.js';
13
+
14
+ /**
15
+ * Ensure memory folder exists in client workspace.
16
+ * Creates context/memory/ if it doesn't exist (for existing clients).
17
+ * @param {string} clientDir - Path to client directory
18
+ */
19
+ export async function ensureMemoryFolder(clientDir) {
20
+ const memoryDir = join(clientDir, 'context', 'memory');
21
+ if (!existsSync(memoryDir)) {
22
+ await ensureDir(memoryDir);
23
+ logger.info(' Created context/memory/ folder');
24
+ }
25
+ }
26
+
27
+ /**
28
+ * One-time migration: merge deny rules into settings.local.json.
29
+ * Adds missing deny entries without touching existing config.
30
+ */
31
+ export function migrateSettingsDenyRules(clientDir) {
32
+ const settingsPath = join(clientDir, '.claude', 'settings.local.json');
33
+ if (!existsSync(settingsPath)) return;
34
+
35
+ const requiredDeny = [
36
+ 'Read(./.env)',
37
+ 'Read(./.env.*)',
38
+ 'Read(./secrets/**)'
39
+ ];
40
+
41
+ try {
42
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
43
+ if (!settings.permissions) settings.permissions = {};
44
+ const existing = settings.permissions.deny || [];
45
+ const toAdd = requiredDeny.filter(rule => !existing.includes(rule));
46
+
47
+ if (toAdd.length === 0) return;
48
+
49
+ settings.permissions.deny = [...existing, ...toAdd];
50
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
51
+ logger.info(` Added ${toAdd.length} deny rule${toAdd.length !== 1 ? 's' : ''} to settings.local.json`);
52
+ } catch {
53
+ // Don't fail update over migration
54
+ }
55
+ }
56
+
57
+ /**
58
+ * One-time migration: merge hooks config into settings.local.json.
59
+ * Adds missing hook events without touching existing ones.
60
+ */
61
+ export function migrateSettingsHooks(clientDir) {
62
+ const settingsPath = join(clientDir, '.claude', 'settings.local.json');
63
+ if (!existsSync(settingsPath)) return;
64
+
65
+ const requiredHooks = {
66
+ SessionStart: [
67
+ {
68
+ matcher: 'startup|resume|clear',
69
+ hooks: [
70
+ {
71
+ type: 'command',
72
+ command: '.claude/hooks/session-context-check.sh',
73
+ timeout: 30,
74
+ statusMessage: 'Checking context freshness...'
75
+ }
76
+ ]
77
+ }
78
+ ],
79
+ Stop: [
80
+ {
81
+ hooks: [
82
+ {
83
+ type: 'command',
84
+ command: '.claude/hooks/stop-memory-check.sh',
85
+ timeout: 10,
86
+ statusMessage: 'Checking memory log...'
87
+ }
88
+ ]
89
+ }
90
+ ],
91
+ PostCompact: [
92
+ {
93
+ hooks: [
94
+ {
95
+ type: 'command',
96
+ command: '.claude/hooks/post-compact-reminder.sh',
97
+ timeout: 30,
98
+ statusMessage: 'Restoring context after compaction...'
99
+ }
100
+ ]
101
+ }
102
+ ]
103
+ };
104
+
105
+ try {
106
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
107
+ if (!settings.hooks) settings.hooks = {};
108
+
109
+ const added = [];
110
+ for (const [event, config] of Object.entries(requiredHooks)) {
111
+ if (!settings.hooks[event]) {
112
+ settings.hooks[event] = config;
113
+ added.push(event);
114
+ }
115
+ }
116
+
117
+ if (added.length === 0) return;
118
+
119
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
120
+ logger.info(` Added ${added.length} hook${added.length !== 1 ? 's' : ''} to settings.local.json (${added.join(', ')})`);
121
+ } catch {
122
+ // Don't fail update over migration
123
+ }
124
+ }
125
+
126
+ /**
127
+ * One-time migration: normalize backslash manifest keys to forward slashes.
128
+ * On Windows, getAllFiles() returned backslash paths which were stored as manifest keys.
129
+ * This normalizes them so lookups work cross-platform.
130
+ */
131
+ export function migrateManifestKeys(manifest) {
132
+ let fixed = 0;
133
+ for (const [key, value] of Object.entries(manifest.managedFiles)) {
134
+ const normalized = key.replace(/\\/g, '/');
135
+ if (normalized !== key) {
136
+ delete manifest.managedFiles[key];
137
+ manifest.managedFiles[normalized] = value;
138
+ fixed++;
139
+ }
140
+ }
141
+ for (const conflict of (manifest.conflicts || [])) {
142
+ if (conflict.file) {
143
+ conflict.file = conflict.file.replace(/\\/g, '/');
144
+ }
145
+ }
146
+ if (fixed > 0) {
147
+ logger.info(` Normalized ${fixed} manifest key${fixed !== 1 ? 's' : ''}`);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * One-time migration: fix managedType for config files.
153
+ * On Windows, isConfigFile() failed due to backslash paths, so config files
154
+ * got stored with managedType 'update' instead of 'config'.
155
+ * This corrects existing manifests in-place (before detectModifications runs).
156
+ */
157
+ export function migrateConfigManagedTypes(manifest) {
158
+ let fixed = 0;
159
+ for (const [path, info] of Object.entries(manifest.managedFiles)) {
160
+ if (isConfigFile(path) && info.managedType !== 'config') {
161
+ info.managedType = 'config';
162
+ fixed++;
163
+ }
164
+ }
165
+ if (fixed > 0) {
166
+ logger.info(` Fixed managedType for ${fixed} config file${fixed !== 1 ? 's' : ''}`);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * One-time migration: remove leaked data files from skill tmp/ directories.
172
+ * Data files were accidentally included in .claude-base.zip and distributed
173
+ * to clients. This removes them and cleans up the manifest.
174
+ */
175
+ export function migrateTmpDataFiles(clientDir, manifest) {
176
+ const keepFiles = new Set(['.gitkeep', '.gitignore']);
177
+ let removed = 0;
178
+ for (const [path] of Object.entries(manifest.managedFiles)) {
179
+ if (!/\/tmp\//.test(path)) continue;
180
+ const filename = path.split('/').pop();
181
+ if (keepFiles.has(filename)) continue;
182
+ const fullPath = join(clientDir, path);
183
+ if (existsSync(fullPath)) unlinkSync(fullPath);
184
+ delete manifest.managedFiles[path];
185
+ removed++;
186
+ }
187
+ if (removed > 0) {
188
+ logger.info(` Removed ${removed} tmp data file${removed !== 1 ? 's' : ''} from tmp/`);
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Run all client migrations in order.
194
+ * @param {string} clientDir - Path to client directory
195
+ * @param {object} manifest - The manifest object (mutated in place)
196
+ */
197
+ export async function runAllMigrations(clientDir, manifest) {
198
+ await ensureMemoryFolder(clientDir);
199
+ migrateSettingsDenyRules(clientDir);
200
+ migrateSettingsHooks(clientDir);
201
+ migrateManifestKeys(manifest);
202
+ migrateConfigManagedTypes(manifest);
203
+ migrateTmpDataFiles(clientDir, manifest);
204
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Shared path, version, and client discovery helpers.
3
+ *
4
+ * Single source of truth — command files import from here
5
+ * instead of duplicating these functions.
6
+ */
7
+
8
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { manifestExists } from './manifest.js';
12
+
13
+ // Package root: two levels up from lib/utils/
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+ const PACKAGE_ROOT = join(__dirname, '..', '..');
17
+
18
+ /**
19
+ * Get package version from package.json
20
+ * @returns {string}
21
+ */
22
+ export function getPackageVersion() {
23
+ const pkgPath = join(PACKAGE_ROOT, 'package.json');
24
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
25
+ return pkg.version;
26
+ }
27
+
28
+ /**
29
+ * Get the package root directory
30
+ * @returns {string}
31
+ */
32
+ export function getPackageRoot() {
33
+ return PACKAGE_ROOT;
34
+ }
35
+
36
+ /**
37
+ * Get path to .claude-base template directory
38
+ * @returns {string}
39
+ */
40
+ export function getBaseTemplatePath() {
41
+ return join(PACKAGE_ROOT, '.claude-base');
42
+ }
43
+
44
+ /**
45
+ * Get path to hub-base template directory
46
+ * @returns {string}
47
+ */
48
+ export function getHubBasePath() {
49
+ return join(PACKAGE_ROOT, 'hub-base');
50
+ }
51
+
52
+ /**
53
+ * Get clients directory path
54
+ * @param {object} [config] - Optional config with settings.clientsDirectory
55
+ * @returns {string}
56
+ */
57
+ export function getClientsDir(config) {
58
+ if (config?.settings?.clientsDirectory) {
59
+ return join(process.cwd(), config.settings.clientsDirectory);
60
+ }
61
+ return join(process.cwd(), 'clients');
62
+ }
63
+
64
+ /**
65
+ * Find all client directories with .managed.json
66
+ * @returns {string[]} Array of client names
67
+ */
68
+ export function discoverClients() {
69
+ const clientsDir = getClientsDir();
70
+
71
+ if (!existsSync(clientsDir)) {
72
+ return [];
73
+ }
74
+
75
+ const entries = readdirSync(clientsDir, { withFileTypes: true });
76
+ const clients = [];
77
+
78
+ for (const entry of entries) {
79
+ if (entry.isDirectory()) {
80
+ const clientDir = join(clientsDir, entry.name);
81
+ if (manifestExists(clientDir)) {
82
+ clients.push(entry.name);
83
+ }
84
+ }
85
+ }
86
+
87
+ return clients;
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ppcos",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "CLI tool to manage Google Ads AI workflow skills and agents for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {