frontier-os-app-builder 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -0
- package/agents/fos-executor.md +460 -0
- package/agents/fos-plan-checker.md +386 -0
- package/agents/fos-planner.md +416 -0
- package/agents/fos-researcher.md +358 -0
- package/agents/fos-verifier.md +491 -0
- package/bin/fos-tools.cjs +794 -0
- package/bin/install.js +234 -0
- package/commands/fos/add-feature.md +29 -0
- package/commands/fos/discuss.md +31 -0
- package/commands/fos/execute.md +35 -0
- package/commands/fos/new-app.md +39 -0
- package/commands/fos/new-milestone.md +28 -0
- package/commands/fos/next.md +29 -0
- package/commands/fos/plan.md +37 -0
- package/commands/fos/ship.md +29 -0
- package/commands/fos/status.md +22 -0
- package/package.json +30 -0
- package/references/app-patterns.md +501 -0
- package/references/deployment.md +395 -0
- package/references/module-inference.md +349 -0
- package/references/sdk-surface.md +1622 -0
- package/references/verification-rules.md +404 -0
- package/templates/app/gitignore +25 -0
- package/templates/app/index.css +111 -0
- package/templates/app/index.html +19 -0
- package/templates/app/layout.tsx +45 -0
- package/templates/app/main-router.tsx +17 -0
- package/templates/app/main-simple.tsx +19 -0
- package/templates/app/package.json +36 -0
- package/templates/app/postcss.config.js +5 -0
- package/templates/app/router.tsx +22 -0
- package/templates/app/sdk-context.tsx +33 -0
- package/templates/app/test-setup.ts +19 -0
- package/templates/app/tsconfig.json +22 -0
- package/templates/app/vercel.json +127 -0
- package/templates/app/vite.config.ts +15 -0
- package/templates/state/context.md +248 -0
- package/templates/state/manifest.json +11 -0
- package/templates/state/plan.md +187 -0
- package/templates/state/project.md +118 -0
- package/templates/state/requirements.md +133 -0
- package/templates/state/roadmap.md +129 -0
- package/templates/state/state.md +131 -0
- package/templates/state/summary.md +273 -0
- package/workflows/add-feature.md +234 -0
- package/workflows/discuss.md +310 -0
- package/workflows/execute-plan.md +222 -0
- package/workflows/execute.md +338 -0
- package/workflows/new-app.md +331 -0
- package/workflows/new-milestone.md +258 -0
- package/workflows/next.md +157 -0
- package/workflows/plan.md +310 -0
- package/workflows/ship.md +296 -0
- package/workflows/status.md +145 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
// ─────────────────────────────────────────────
|
|
9
|
+
// FOS Tools — CLI utility for Frontier OS App Builder
|
|
10
|
+
// Usage: node fos-tools.cjs <command> [args] [--raw] [--pick <field>]
|
|
11
|
+
// ─────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const VERSION = '1.0.0';
|
|
14
|
+
|
|
15
|
+
// ── Helpers ──────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function fosDir(cwd) {
|
|
18
|
+
return path.join(cwd || process.cwd(), '.frontier-app');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fosExists(cwd) {
|
|
22
|
+
return fs.existsSync(fosDir(cwd));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readFile(filePath) {
|
|
26
|
+
if (!fs.existsSync(filePath)) return null;
|
|
27
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeFile(filePath, content) {
|
|
31
|
+
const dir = path.dirname(filePath);
|
|
32
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function output(data, flags) {
|
|
37
|
+
if (flags.pick && typeof data === 'object') {
|
|
38
|
+
const val = data[flags.pick];
|
|
39
|
+
process.stdout.write(typeof val === 'string' ? val : JSON.stringify(val));
|
|
40
|
+
} else if (flags.raw && typeof data === 'string') {
|
|
41
|
+
process.stdout.write(data);
|
|
42
|
+
} else {
|
|
43
|
+
const json = JSON.stringify(data, null, 2);
|
|
44
|
+
// If output is too large for stdout, write to temp file
|
|
45
|
+
if (json.length > 50000) {
|
|
46
|
+
const tmp = path.join(require('os').tmpdir(), `fos-${Date.now()}.json`);
|
|
47
|
+
fs.writeFileSync(tmp, json);
|
|
48
|
+
process.stdout.write(`@file:${tmp}`);
|
|
49
|
+
} else {
|
|
50
|
+
process.stdout.write(json);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function error(msg) {
|
|
56
|
+
process.stderr.write(`Error: ${msg}\n`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function generateSlug(text) {
|
|
61
|
+
return text
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
64
|
+
.replace(/\s+/g, '-')
|
|
65
|
+
.replace(/-+/g, '-')
|
|
66
|
+
.replace(/^-|-$/g, '')
|
|
67
|
+
.slice(0, 50);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── YAML Frontmatter Parsing ─────────────────
|
|
71
|
+
|
|
72
|
+
function parseFrontmatter(content) {
|
|
73
|
+
if (!content) return { frontmatter: {}, body: '' };
|
|
74
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
75
|
+
if (!match) return { frontmatter: {}, body: content };
|
|
76
|
+
|
|
77
|
+
const fm = {};
|
|
78
|
+
let currentKey = null;
|
|
79
|
+
let currentIndent = 0;
|
|
80
|
+
|
|
81
|
+
for (const line of match[1].split('\n')) {
|
|
82
|
+
const kvMatch = line.match(/^(\w[\w_-]*)\s*:\s*(.*)$/);
|
|
83
|
+
if (kvMatch) {
|
|
84
|
+
const [, key, val] = kvMatch;
|
|
85
|
+
const trimmed = val.trim();
|
|
86
|
+
if (trimmed === '' || trimmed === '|') {
|
|
87
|
+
fm[key] = '';
|
|
88
|
+
currentKey = key;
|
|
89
|
+
} else if (trimmed === '[]') {
|
|
90
|
+
fm[key] = [];
|
|
91
|
+
currentKey = key;
|
|
92
|
+
} else if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
93
|
+
fm[key] = trimmed.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
|
|
94
|
+
currentKey = null;
|
|
95
|
+
} else if (trimmed === 'true') {
|
|
96
|
+
fm[key] = true; currentKey = null;
|
|
97
|
+
} else if (trimmed === 'false') {
|
|
98
|
+
fm[key] = false; currentKey = null;
|
|
99
|
+
} else if (/^\d+$/.test(trimmed)) {
|
|
100
|
+
fm[key] = parseInt(trimmed, 10); currentKey = null;
|
|
101
|
+
} else {
|
|
102
|
+
fm[key] = trimmed.replace(/^["']|["']$/g, '');
|
|
103
|
+
currentKey = null;
|
|
104
|
+
}
|
|
105
|
+
} else if (currentKey && line.match(/^\s+-\s+/)) {
|
|
106
|
+
const item = line.replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, '');
|
|
107
|
+
if (!Array.isArray(fm[currentKey])) fm[currentKey] = [];
|
|
108
|
+
fm[currentKey].push(item);
|
|
109
|
+
} else if (currentKey && line.match(/^\s+\S/)) {
|
|
110
|
+
fm[currentKey] += (fm[currentKey] ? '\n' : '') + line.trimStart();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { frontmatter: fm, body: match[2] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function serializeFrontmatter(fm, body) {
|
|
118
|
+
const lines = ['---'];
|
|
119
|
+
for (const [key, val] of Object.entries(fm)) {
|
|
120
|
+
if (Array.isArray(val)) {
|
|
121
|
+
if (val.length === 0) {
|
|
122
|
+
lines.push(`${key}: []`);
|
|
123
|
+
} else {
|
|
124
|
+
lines.push(`${key}:`);
|
|
125
|
+
val.forEach(v => lines.push(` - ${v}`));
|
|
126
|
+
}
|
|
127
|
+
} else if (typeof val === 'boolean') {
|
|
128
|
+
lines.push(`${key}: ${val}`);
|
|
129
|
+
} else if (typeof val === 'number') {
|
|
130
|
+
lines.push(`${key}: ${val}`);
|
|
131
|
+
} else {
|
|
132
|
+
lines.push(`${key}: ${val}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
lines.push('---');
|
|
136
|
+
return lines.join('\n') + '\n' + (body || '');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Manifest ─────────────────────────────────
|
|
140
|
+
|
|
141
|
+
function loadManifest(cwd) {
|
|
142
|
+
const p = path.join(fosDir(cwd), 'manifest.json');
|
|
143
|
+
const raw = readFile(p);
|
|
144
|
+
if (!raw) return null;
|
|
145
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function saveManifest(cwd, manifest) {
|
|
149
|
+
writeFile(path.join(fosDir(cwd), 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── State ────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function loadState(cwd) {
|
|
155
|
+
const p = path.join(fosDir(cwd), 'STATE.md');
|
|
156
|
+
const raw = readFile(p);
|
|
157
|
+
if (!raw) return null;
|
|
158
|
+
return parseFrontmatter(raw);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function saveState(cwd, fm, body) {
|
|
162
|
+
writeFile(path.join(fosDir(cwd), 'STATE.md'), serializeFrontmatter(fm, body));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cmdStateLoad(cwd, flags) {
|
|
166
|
+
const state = loadState(cwd);
|
|
167
|
+
if (!state) error('.frontier-app/STATE.md not found');
|
|
168
|
+
output(flags.raw ? state.body : state, flags);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function cmdStateJson(cwd, flags) {
|
|
172
|
+
const state = loadState(cwd);
|
|
173
|
+
if (!state) error('.frontier-app/STATE.md not found');
|
|
174
|
+
output(state.frontmatter, flags);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function cmdStateUpdate(cwd, field, value, flags) {
|
|
178
|
+
const state = loadState(cwd);
|
|
179
|
+
if (!state) error('.frontier-app/STATE.md not found');
|
|
180
|
+
// Parse value
|
|
181
|
+
let parsed = value;
|
|
182
|
+
if (value === 'true') parsed = true;
|
|
183
|
+
else if (value === 'false') parsed = false;
|
|
184
|
+
else if (/^\d+$/.test(value)) parsed = parseInt(value, 10);
|
|
185
|
+
|
|
186
|
+
state.frontmatter[field] = parsed;
|
|
187
|
+
saveState(cwd, state.frontmatter, state.body);
|
|
188
|
+
output({ updated: field, value: parsed }, flags);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function cmdStateGet(cwd, field, flags) {
|
|
192
|
+
const state = loadState(cwd);
|
|
193
|
+
if (!state) error('.frontier-app/STATE.md not found');
|
|
194
|
+
const val = state.frontmatter[field];
|
|
195
|
+
if (val === undefined) error(`Field '${field}' not found in STATE.md`);
|
|
196
|
+
output(flags.raw ? String(val) : { [field]: val }, flags);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Phase Operations ─────────────────────────
|
|
200
|
+
|
|
201
|
+
function findPhaseDir(cwd, phaseNum) {
|
|
202
|
+
const phasesDir = path.join(fosDir(cwd), 'phases');
|
|
203
|
+
if (!fs.existsSync(phasesDir)) return null;
|
|
204
|
+
|
|
205
|
+
const prefix = String(phaseNum).padStart(2, '0');
|
|
206
|
+
const entries = fs.readdirSync(phasesDir);
|
|
207
|
+
const match = entries.find(e => e.startsWith(prefix + '-'));
|
|
208
|
+
return match ? path.join(phasesDir, match) : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function cmdFindPhase(cwd, phaseNum, flags) {
|
|
212
|
+
if (!phaseNum) error('Phase number required');
|
|
213
|
+
const dir = findPhaseDir(cwd, phaseNum);
|
|
214
|
+
if (!dir) error(`Phase ${phaseNum} directory not found`);
|
|
215
|
+
output(flags.raw ? dir : { phase: phaseNum, directory: dir }, flags);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function listPlans(phaseDir) {
|
|
219
|
+
if (!phaseDir || !fs.existsSync(phaseDir)) return [];
|
|
220
|
+
return fs.readdirSync(phaseDir)
|
|
221
|
+
.filter(f => f.match(/^\d{2}-\d{2}-PLAN\.md$/))
|
|
222
|
+
.sort();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function listSummaries(phaseDir) {
|
|
226
|
+
if (!phaseDir || !fs.existsSync(phaseDir)) return [];
|
|
227
|
+
return fs.readdirSync(phaseDir)
|
|
228
|
+
.filter(f => f.match(/^\d{2}-\d{2}-SUMMARY\.md$/))
|
|
229
|
+
.sort();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Scaffold ─────────────────────────────────
|
|
233
|
+
|
|
234
|
+
function cmdScaffold(templateName, varsJson, flags) {
|
|
235
|
+
const fosHome = process.env.FOS_HOME || path.join(require('os').homedir(), '.claude', 'frontier-os-app-builder');
|
|
236
|
+
const templatePath = path.join(fosHome, 'templates', templateName);
|
|
237
|
+
|
|
238
|
+
if (!fs.existsSync(templatePath)) {
|
|
239
|
+
error(`Template not found: ${templateName} (looked at ${templatePath})`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let content = fs.readFileSync(templatePath, 'utf-8');
|
|
243
|
+
|
|
244
|
+
// Parse vars
|
|
245
|
+
let vars = {};
|
|
246
|
+
if (varsJson) {
|
|
247
|
+
try { vars = JSON.parse(varsJson); } catch { error(`Invalid JSON vars: ${varsJson}`); }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Substitute {{VAR}} patterns
|
|
251
|
+
for (const [key, val] of Object.entries(vars)) {
|
|
252
|
+
const pattern = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
|
|
253
|
+
content = content.replace(pattern, val);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (flags.raw) {
|
|
257
|
+
process.stdout.write(content);
|
|
258
|
+
} else {
|
|
259
|
+
output({ template: templateName, vars: Object.keys(vars), content }, flags);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Module Inference ─────────────────────────
|
|
264
|
+
|
|
265
|
+
const MODULE_KEYWORDS = {
|
|
266
|
+
Wallet: {
|
|
267
|
+
keywords: ['payment', 'pay', 'charge', 'pos', 'checkout', 'purchase', 'buy', 'sell',
|
|
268
|
+
'transfer', 'send money', 'balance', 'funds', 'money', 'wallet', 'fnd',
|
|
269
|
+
'swap', 'exchange', 'convert', 'token', 'deposit', 'on-ramp', 'fund',
|
|
270
|
+
'withdraw', 'off-ramp', 'bank', 'fiat', 'subscription', 'billing', 'price',
|
|
271
|
+
'cost', 'fee', 'tip', 'donate', 'donation'],
|
|
272
|
+
getter: 'sdk.getWallet()',
|
|
273
|
+
commonMethods: ['getBalance', 'getBalanceFormatted', 'transferFrontierDollar', 'payWithFrontierDollar'],
|
|
274
|
+
permissions: ['wallet:getBalance', 'wallet:getBalanceFormatted', 'wallet:getAddress',
|
|
275
|
+
'wallet:transferFrontierDollar', 'wallet:payWithFrontierDollar']
|
|
276
|
+
},
|
|
277
|
+
User: {
|
|
278
|
+
keywords: ['user', 'profile', 'account', 'member', 'membership', 'auth', 'login',
|
|
279
|
+
'referral', 'invite', 'refer', 'signup', 'register', 'kyc', 'verify',
|
|
280
|
+
'identity', 'access control', 'gate', 'permission', 'name', 'person'],
|
|
281
|
+
getter: 'sdk.getUser()',
|
|
282
|
+
commonMethods: ['getDetails', 'getProfile', 'getVerifiedAccessControls'],
|
|
283
|
+
permissions: ['user:getDetails', 'user:getProfile', 'user:getVerifiedAccessControls']
|
|
284
|
+
},
|
|
285
|
+
Events: {
|
|
286
|
+
keywords: ['event', 'meetup', 'gathering', 'calendar', 'schedule', 'room', 'booking',
|
|
287
|
+
'reserve', 'reservation', 'space', 'venue', 'location', 'conference',
|
|
288
|
+
'meeting', 'coworking'],
|
|
289
|
+
getter: 'sdk.getEvents()',
|
|
290
|
+
commonMethods: ['listEvents', 'createEvent', 'listLocations', 'createRoomBooking'],
|
|
291
|
+
permissions: ['events:listEvents', 'events:createEvent', 'events:listLocations',
|
|
292
|
+
'events:listRoomBookings', 'events:createRoomBooking']
|
|
293
|
+
},
|
|
294
|
+
Communities: {
|
|
295
|
+
keywords: ['community', 'group', 'team', 'club', 'internship', 'intern', 'cohort',
|
|
296
|
+
'reassign', 'transfer member', 'collective', 'society'],
|
|
297
|
+
getter: 'sdk.getCommunities()',
|
|
298
|
+
commonMethods: ['listCommunities', 'getCommunity'],
|
|
299
|
+
permissions: ['communities:listCommunities', 'communities:getCommunity']
|
|
300
|
+
},
|
|
301
|
+
Partnerships: {
|
|
302
|
+
keywords: ['sponsor', 'partnership', 'partner', 'sponsorship', 'benefactor',
|
|
303
|
+
'supporter', 'patron'],
|
|
304
|
+
getter: 'sdk.getPartnerships()',
|
|
305
|
+
commonMethods: ['listSponsors', 'getSponsor', 'createSponsorPass'],
|
|
306
|
+
permissions: ['partnerships:listSponsors', 'partnerships:getSponsor',
|
|
307
|
+
'partnerships:createSponsorPass']
|
|
308
|
+
},
|
|
309
|
+
Offices: {
|
|
310
|
+
keywords: ['office', 'access pass', 'building', 'door', 'entry', 'visitor',
|
|
311
|
+
'check-in', 'checkin', 'physical access', 'facility'],
|
|
312
|
+
getter: 'sdk.getOffices()',
|
|
313
|
+
commonMethods: ['createAccessPass', 'listAccessPasses'],
|
|
314
|
+
permissions: ['offices:createAccessPass', 'offices:listAccessPasses']
|
|
315
|
+
},
|
|
316
|
+
ThirdParty: {
|
|
317
|
+
keywords: ['developer', 'api key', 'webhook', 'app registration', 'app store',
|
|
318
|
+
'third party', 'integration', 'external', 'developer portal'],
|
|
319
|
+
getter: 'sdk.getThirdParty()',
|
|
320
|
+
commonMethods: ['listDevelopers', 'createApp', 'createWebhook'],
|
|
321
|
+
permissions: ['thirdParty:listDevelopers', 'thirdParty:createApp']
|
|
322
|
+
},
|
|
323
|
+
Storage: {
|
|
324
|
+
keywords: ['storage', 'persist', 'save', 'preferences', 'settings', 'cache',
|
|
325
|
+
'remember', 'state', 'draft', 'favorites', 'bookmarks'],
|
|
326
|
+
getter: 'sdk.getStorage()',
|
|
327
|
+
commonMethods: ['get', 'set', 'remove', 'clear'],
|
|
328
|
+
permissions: ['storage:get', 'storage:set', 'storage:remove']
|
|
329
|
+
},
|
|
330
|
+
Chain: {
|
|
331
|
+
keywords: ['network', 'chain', 'blockchain', 'contract', 'smart contract',
|
|
332
|
+
'on-chain', 'token address', 'stablecoin'],
|
|
333
|
+
getter: 'sdk.getChain()',
|
|
334
|
+
commonMethods: ['getCurrentNetwork', 'getContractAddresses', 'getCurrentChainConfig'],
|
|
335
|
+
permissions: ['chain:getCurrentNetwork', 'chain:getContractAddresses']
|
|
336
|
+
},
|
|
337
|
+
Navigation: {
|
|
338
|
+
keywords: ['navigate', 'deep link', 'deeplink', 'open app', 'app link',
|
|
339
|
+
'cross-app', 'redirect', 'launch app', 'inter-app'],
|
|
340
|
+
getter: 'sdk.getNavigation()',
|
|
341
|
+
commonMethods: ['openApp', 'close', 'onDeepLink'],
|
|
342
|
+
permissions: ['navigation:openApp', 'navigation:close']
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// Modules that are always included
|
|
347
|
+
const BASE_MODULES = ['Storage', 'Chain'];
|
|
348
|
+
// User is included if any user-facing feature is detected
|
|
349
|
+
const USER_TRIGGER_MODULES = ['Wallet', 'Events', 'Communities', 'Partnerships', 'Offices'];
|
|
350
|
+
|
|
351
|
+
function cmdInferModules(description, flags) {
|
|
352
|
+
if (!description) error('Description required');
|
|
353
|
+
|
|
354
|
+
const desc = description.toLowerCase();
|
|
355
|
+
const matched = new Set(BASE_MODULES);
|
|
356
|
+
const matchDetails = {};
|
|
357
|
+
|
|
358
|
+
for (const [moduleName, config] of Object.entries(MODULE_KEYWORDS)) {
|
|
359
|
+
const hits = config.keywords.filter(kw => desc.includes(kw));
|
|
360
|
+
if (hits.length > 0) {
|
|
361
|
+
matched.add(moduleName);
|
|
362
|
+
matchDetails[moduleName] = {
|
|
363
|
+
matchedKeywords: hits,
|
|
364
|
+
getter: config.getter,
|
|
365
|
+
suggestedMethods: config.commonMethods,
|
|
366
|
+
permissions: config.permissions
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Add User if any user-facing module was matched
|
|
372
|
+
if (USER_TRIGGER_MODULES.some(m => matched.has(m))) {
|
|
373
|
+
matched.add('User');
|
|
374
|
+
if (!matchDetails.User) {
|
|
375
|
+
matchDetails.User = {
|
|
376
|
+
matchedKeywords: ['(implied by user-facing features)'],
|
|
377
|
+
getter: MODULE_KEYWORDS.User.getter,
|
|
378
|
+
suggestedMethods: MODULE_KEYWORDS.User.commonMethods,
|
|
379
|
+
permissions: MODULE_KEYWORDS.User.permissions
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Add base module details
|
|
385
|
+
for (const base of BASE_MODULES) {
|
|
386
|
+
if (!matchDetails[base]) {
|
|
387
|
+
matchDetails[base] = {
|
|
388
|
+
matchedKeywords: ['(always included)'],
|
|
389
|
+
getter: MODULE_KEYWORDS[base].getter,
|
|
390
|
+
suggestedMethods: MODULE_KEYWORDS[base].commonMethods,
|
|
391
|
+
permissions: MODULE_KEYWORDS[base].permissions
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Collect all permissions
|
|
397
|
+
const allPermissions = [];
|
|
398
|
+
for (const mod of matched) {
|
|
399
|
+
const perms = MODULE_KEYWORDS[mod]?.permissions || [];
|
|
400
|
+
allPermissions.push(...perms);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
output({
|
|
404
|
+
description,
|
|
405
|
+
modules: Array.from(matched).sort(),
|
|
406
|
+
details: matchDetails,
|
|
407
|
+
permissions: allPermissions,
|
|
408
|
+
moduleCount: matched.size
|
|
409
|
+
}, flags);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Validation ───────────────────────────────
|
|
413
|
+
|
|
414
|
+
function cmdValidateStructure(cwd, flags) {
|
|
415
|
+
const issues = [];
|
|
416
|
+
const checks = [];
|
|
417
|
+
|
|
418
|
+
const requiredFiles = [
|
|
419
|
+
'src/lib/sdk-context.tsx',
|
|
420
|
+
'src/views/Layout.tsx',
|
|
421
|
+
'src/main.tsx',
|
|
422
|
+
'src/styles/index.css',
|
|
423
|
+
'vite.config.ts',
|
|
424
|
+
'tsconfig.json',
|
|
425
|
+
'postcss.config.js',
|
|
426
|
+
'vercel.json',
|
|
427
|
+
'index.html',
|
|
428
|
+
'package.json'
|
|
429
|
+
];
|
|
430
|
+
|
|
431
|
+
for (const file of requiredFiles) {
|
|
432
|
+
const exists = fs.existsSync(path.join(cwd, file));
|
|
433
|
+
checks.push({ file, exists });
|
|
434
|
+
if (!exists) issues.push(`Missing required file: ${file}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Check vercel.json has all 5 CORS origins
|
|
438
|
+
const vercelPath = path.join(cwd, 'vercel.json');
|
|
439
|
+
if (fs.existsSync(vercelPath)) {
|
|
440
|
+
const vercel = readFile(vercelPath);
|
|
441
|
+
const origins = [
|
|
442
|
+
'http://localhost:5173',
|
|
443
|
+
'https://sandbox.os.frontiertower.io',
|
|
444
|
+
'https://alpha.os.frontiertower.io',
|
|
445
|
+
'https://beta.os.frontiertower.io',
|
|
446
|
+
'https://os.frontiertower.io'
|
|
447
|
+
];
|
|
448
|
+
for (const origin of origins) {
|
|
449
|
+
if (!vercel.includes(origin)) {
|
|
450
|
+
issues.push(`vercel.json missing CORS origin: ${origin}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Check Layout.tsx has iframe detection
|
|
456
|
+
const layoutPath = path.join(cwd, 'src/views/Layout.tsx');
|
|
457
|
+
if (fs.existsSync(layoutPath)) {
|
|
458
|
+
const layout = readFile(layoutPath);
|
|
459
|
+
if (!layout.includes('isInFrontierApp')) {
|
|
460
|
+
issues.push('Layout.tsx missing isInFrontierApp() check');
|
|
461
|
+
}
|
|
462
|
+
if (!layout.includes('createStandaloneHTML') && !layout.includes('renderStandaloneMessage')) {
|
|
463
|
+
issues.push('Layout.tsx missing standalone fallback');
|
|
464
|
+
}
|
|
465
|
+
if (!layout.includes('SdkProvider')) {
|
|
466
|
+
issues.push('Layout.tsx missing SdkProvider wrapping');
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Check index.html has dark class
|
|
471
|
+
const htmlPath = path.join(cwd, 'index.html');
|
|
472
|
+
if (fs.existsSync(htmlPath)) {
|
|
473
|
+
const html = readFile(htmlPath);
|
|
474
|
+
if (!html.includes('class="dark"')) {
|
|
475
|
+
issues.push('index.html missing class="dark" on body');
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Check test setup exists
|
|
480
|
+
const testSetup = path.join(cwd, 'src/test/setup.ts');
|
|
481
|
+
if (!fs.existsSync(testSetup)) {
|
|
482
|
+
issues.push('Missing test setup: src/test/setup.ts');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
output({
|
|
486
|
+
pass: issues.length === 0,
|
|
487
|
+
checks,
|
|
488
|
+
issues,
|
|
489
|
+
checkedFiles: requiredFiles.length
|
|
490
|
+
}, flags);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function cmdValidatePermissions(cwd, flags) {
|
|
494
|
+
const manifest = loadManifest(cwd);
|
|
495
|
+
if (!manifest) error('.frontier-app/manifest.json not found');
|
|
496
|
+
|
|
497
|
+
const issues = [];
|
|
498
|
+
const srcDir = path.join(cwd, 'src');
|
|
499
|
+
|
|
500
|
+
// Find all SDK method calls in source
|
|
501
|
+
const usedMethods = new Set();
|
|
502
|
+
function scanDir(dir) {
|
|
503
|
+
if (!fs.existsSync(dir)) return;
|
|
504
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
505
|
+
if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== 'test') {
|
|
506
|
+
scanDir(path.join(dir, entry.name));
|
|
507
|
+
} else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
|
|
508
|
+
const content = readFile(path.join(dir, entry.name));
|
|
509
|
+
// Match sdk.getX().methodName() or getX().methodName() patterns
|
|
510
|
+
const calls = content.match(/\.(getWallet|getUser|getStorage|getChain|getEvents|getCommunities|getPartnerships|getOffices|getThirdParty)\(\)\.\w+/g);
|
|
511
|
+
if (calls) calls.forEach(c => usedMethods.add(c.slice(1)));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
scanDir(srcDir);
|
|
516
|
+
|
|
517
|
+
// Map method calls to required permissions
|
|
518
|
+
const methodToPermission = {};
|
|
519
|
+
for (const [mod, config] of Object.entries(MODULE_KEYWORDS)) {
|
|
520
|
+
for (const perm of config.permissions) {
|
|
521
|
+
const parts = perm.split(':');
|
|
522
|
+
methodToPermission[`${config.getter.replace('sdk.', '')}.${parts[1]}`] = perm;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Check each used method has a corresponding permission
|
|
527
|
+
const declaredPerms = new Set(manifest.permissions || []);
|
|
528
|
+
const missingPerms = [];
|
|
529
|
+
for (const method of usedMethods) {
|
|
530
|
+
// Try to find matching permission
|
|
531
|
+
for (const [pattern, perm] of Object.entries(methodToPermission)) {
|
|
532
|
+
if (method.includes(pattern.split('.')[0]) && !declaredPerms.has(perm)) {
|
|
533
|
+
missingPerms.push({ method, permission: perm });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (missingPerms.length > 0) {
|
|
539
|
+
issues.push(...missingPerms.map(m => `SDK method ${m.method} used but permission ${m.permission} not in manifest`));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
output({
|
|
543
|
+
pass: issues.length === 0,
|
|
544
|
+
declaredPermissions: manifest.permissions,
|
|
545
|
+
usedMethods: Array.from(usedMethods),
|
|
546
|
+
issues,
|
|
547
|
+
missingPermissions: missingPerms
|
|
548
|
+
}, flags);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ── Init (Compound Context Loaders) ──────────
|
|
552
|
+
|
|
553
|
+
function cmdInit(workflow, phaseArg, flags) {
|
|
554
|
+
const cwd = process.cwd();
|
|
555
|
+
|
|
556
|
+
switch (workflow) {
|
|
557
|
+
case 'new-app': {
|
|
558
|
+
const exists = fosExists(cwd);
|
|
559
|
+
const hasGit = fs.existsSync(path.join(cwd, '.git'));
|
|
560
|
+
const templateHome = process.env.FOS_HOME || path.join(require('os').homedir(), '.claude', 'frontier-os-app-builder');
|
|
561
|
+
output({
|
|
562
|
+
project_exists: exists,
|
|
563
|
+
has_git: hasGit,
|
|
564
|
+
cwd,
|
|
565
|
+
template_home: templateHome,
|
|
566
|
+
version: VERSION
|
|
567
|
+
}, flags);
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
case 'discuss': {
|
|
572
|
+
if (!phaseArg) error('Phase number required for discuss init');
|
|
573
|
+
if (!fosExists(cwd)) error('.frontier-app/ not found. Run /fos:new-app first.');
|
|
574
|
+
const manifest = loadManifest(cwd);
|
|
575
|
+
const state = loadState(cwd);
|
|
576
|
+
const phaseDir = findPhaseDir(cwd, phaseArg);
|
|
577
|
+
const hasContext = phaseDir && fs.existsSync(path.join(phaseDir, `${String(phaseArg).padStart(2, '0')}-CONTEXT.md`));
|
|
578
|
+
const roadmap = readFile(path.join(fosDir(cwd), 'ROADMAP.md'));
|
|
579
|
+
|
|
580
|
+
output({
|
|
581
|
+
phase: parseInt(phaseArg),
|
|
582
|
+
phase_dir: phaseDir,
|
|
583
|
+
has_context: hasContext,
|
|
584
|
+
manifest,
|
|
585
|
+
state: state?.frontmatter || {},
|
|
586
|
+
roadmap_path: path.join(fosDir(cwd), 'ROADMAP.md'),
|
|
587
|
+
project_path: path.join(fosDir(cwd), 'PROJECT.md'),
|
|
588
|
+
version: VERSION
|
|
589
|
+
}, flags);
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
case 'plan': {
|
|
594
|
+
if (!phaseArg) error('Phase number required for plan init');
|
|
595
|
+
if (!fosExists(cwd)) error('.frontier-app/ not found. Run /fos:new-app first.');
|
|
596
|
+
const manifest = loadManifest(cwd);
|
|
597
|
+
const state = loadState(cwd);
|
|
598
|
+
const phaseDir = findPhaseDir(cwd, phaseArg);
|
|
599
|
+
const prefix = String(phaseArg).padStart(2, '0');
|
|
600
|
+
const hasContext = phaseDir && fs.existsSync(path.join(phaseDir, `${prefix}-CONTEXT.md`));
|
|
601
|
+
const hasResearch = phaseDir && fs.existsSync(path.join(phaseDir, `${prefix}-RESEARCH.md`));
|
|
602
|
+
const existingPlans = phaseDir ? listPlans(phaseDir) : [];
|
|
603
|
+
|
|
604
|
+
output({
|
|
605
|
+
phase: parseInt(phaseArg),
|
|
606
|
+
phase_dir: phaseDir,
|
|
607
|
+
has_context: hasContext,
|
|
608
|
+
has_research: hasResearch,
|
|
609
|
+
existing_plans: existingPlans,
|
|
610
|
+
manifest,
|
|
611
|
+
state: state?.frontmatter || {},
|
|
612
|
+
project_path: path.join(fosDir(cwd), 'PROJECT.md'),
|
|
613
|
+
roadmap_path: path.join(fosDir(cwd), 'ROADMAP.md'),
|
|
614
|
+
template_home: process.env.FOS_HOME || path.join(require('os').homedir(), '.claude', 'frontier-os-app-builder'),
|
|
615
|
+
version: VERSION
|
|
616
|
+
}, flags);
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
case 'execute': {
|
|
621
|
+
if (!phaseArg) error('Phase number required for execute init');
|
|
622
|
+
if (!fosExists(cwd)) error('.frontier-app/ not found. Run /fos:new-app first.');
|
|
623
|
+
const manifest = loadManifest(cwd);
|
|
624
|
+
const state = loadState(cwd);
|
|
625
|
+
const phaseDir = findPhaseDir(cwd, phaseArg);
|
|
626
|
+
const plans = phaseDir ? listPlans(phaseDir) : [];
|
|
627
|
+
const summaries = phaseDir ? listSummaries(phaseDir) : [];
|
|
628
|
+
const completedPlanIds = summaries.map(s => s.replace('-SUMMARY.md', ''));
|
|
629
|
+
const incompletePlans = plans.filter(p => !completedPlanIds.includes(p.replace('-PLAN.md', '')));
|
|
630
|
+
|
|
631
|
+
output({
|
|
632
|
+
phase: parseInt(phaseArg),
|
|
633
|
+
phase_dir: phaseDir,
|
|
634
|
+
plans,
|
|
635
|
+
summaries,
|
|
636
|
+
incomplete_plans: incompletePlans,
|
|
637
|
+
all_complete: incompletePlans.length === 0,
|
|
638
|
+
manifest,
|
|
639
|
+
state: state?.frontmatter || {},
|
|
640
|
+
project_path: path.join(fosDir(cwd), 'PROJECT.md'),
|
|
641
|
+
roadmap_path: path.join(fosDir(cwd), 'ROADMAP.md'),
|
|
642
|
+
template_home: process.env.FOS_HOME || path.join(require('os').homedir(), '.claude', 'frontier-os-app-builder'),
|
|
643
|
+
version: VERSION
|
|
644
|
+
}, flags);
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
case 'ship': {
|
|
649
|
+
if (!fosExists(cwd)) error('.frontier-app/ not found. Run /fos:new-app first.');
|
|
650
|
+
const manifest = loadManifest(cwd);
|
|
651
|
+
const state = loadState(cwd);
|
|
652
|
+
const roadmap = readFile(path.join(fosDir(cwd), 'ROADMAP.md'));
|
|
653
|
+
|
|
654
|
+
// Check if all phases have verifications
|
|
655
|
+
const phasesDir = path.join(fosDir(cwd), 'phases');
|
|
656
|
+
let allVerified = true;
|
|
657
|
+
if (fs.existsSync(phasesDir)) {
|
|
658
|
+
for (const entry of fs.readdirSync(phasesDir)) {
|
|
659
|
+
const phaseNum = entry.split('-')[0];
|
|
660
|
+
const verifPath = path.join(phasesDir, entry, `${phaseNum}-VERIFICATION.md`);
|
|
661
|
+
if (!fs.existsSync(verifPath)) {
|
|
662
|
+
allVerified = false;
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
output({
|
|
669
|
+
manifest,
|
|
670
|
+
state: state?.frontmatter || {},
|
|
671
|
+
all_verified: allVerified,
|
|
672
|
+
project_path: path.join(fosDir(cwd), 'PROJECT.md'),
|
|
673
|
+
roadmap_path: path.join(fosDir(cwd), 'ROADMAP.md'),
|
|
674
|
+
version: VERSION
|
|
675
|
+
}, flags);
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
default:
|
|
680
|
+
error(`Unknown init workflow: ${workflow}. Valid: new-app, discuss, plan, execute, ship`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ── Commit Helper ────────────────────────────
|
|
685
|
+
|
|
686
|
+
function cmdCommit(message, files, flags) {
|
|
687
|
+
if (!message) error('Commit message required');
|
|
688
|
+
try {
|
|
689
|
+
if (files && files.length > 0) {
|
|
690
|
+
execSync(`git add ${files.map(f => `"${f}"`).join(' ')}`, { stdio: 'pipe' });
|
|
691
|
+
}
|
|
692
|
+
execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { stdio: 'pipe' });
|
|
693
|
+
const hash = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
|
|
694
|
+
output({ committed: true, hash, message }, flags);
|
|
695
|
+
} catch (e) {
|
|
696
|
+
error(`Git commit failed: ${e.message}`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ── Main Router ──────────────────────────────
|
|
701
|
+
|
|
702
|
+
function main() {
|
|
703
|
+
const args = process.argv.slice(2);
|
|
704
|
+
const flags = { raw: false, pick: null };
|
|
705
|
+
|
|
706
|
+
// Extract flags
|
|
707
|
+
const cleanArgs = [];
|
|
708
|
+
for (let i = 0; i < args.length; i++) {
|
|
709
|
+
if (args[i] === '--raw') { flags.raw = true; }
|
|
710
|
+
else if (args[i] === '--pick' && args[i + 1]) { flags.pick = args[++i]; }
|
|
711
|
+
else { cleanArgs.push(args[i]); }
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const [command, ...rest] = cleanArgs;
|
|
715
|
+
|
|
716
|
+
if (!command || command === 'help') {
|
|
717
|
+
console.log(`FOS Tools v${VERSION} — Frontier OS App Builder CLI
|
|
718
|
+
|
|
719
|
+
Usage: node fos-tools.cjs <command> [args] [--raw] [--pick <field>]
|
|
720
|
+
|
|
721
|
+
Commands:
|
|
722
|
+
init <workflow> [phase] Compound context loader
|
|
723
|
+
state load Load STATE.md
|
|
724
|
+
state json STATE.md frontmatter as JSON
|
|
725
|
+
state update <field> <value> Update STATE.md field
|
|
726
|
+
state get <field> Get STATE.md field
|
|
727
|
+
find-phase <N> Find phase directory by number
|
|
728
|
+
scaffold <template> [--vars '{}'] Render template with variable substitution
|
|
729
|
+
infer-modules "<description>" Map description to SDK modules
|
|
730
|
+
validate structure Check app structure matches spec
|
|
731
|
+
validate permissions Check permissions match SDK usage
|
|
732
|
+
commit "<message>" [--files ...] Git add + commit helper
|
|
733
|
+
version Show version
|
|
734
|
+
`);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const cwd = process.cwd();
|
|
739
|
+
|
|
740
|
+
switch (command) {
|
|
741
|
+
case 'version':
|
|
742
|
+
output(flags.raw ? VERSION : { version: VERSION }, flags);
|
|
743
|
+
break;
|
|
744
|
+
|
|
745
|
+
case 'init':
|
|
746
|
+
cmdInit(rest[0], rest[1], flags);
|
|
747
|
+
break;
|
|
748
|
+
|
|
749
|
+
case 'state':
|
|
750
|
+
switch (rest[0]) {
|
|
751
|
+
case 'load': cmdStateLoad(cwd, flags); break;
|
|
752
|
+
case 'json': cmdStateJson(cwd, flags); break;
|
|
753
|
+
case 'update': cmdStateUpdate(cwd, rest[1], rest[2], flags); break;
|
|
754
|
+
case 'get': cmdStateGet(cwd, rest[1], flags); break;
|
|
755
|
+
default: error('Unknown state subcommand. Valid: load, json, update, get');
|
|
756
|
+
}
|
|
757
|
+
break;
|
|
758
|
+
|
|
759
|
+
case 'find-phase':
|
|
760
|
+
cmdFindPhase(cwd, rest[0], flags);
|
|
761
|
+
break;
|
|
762
|
+
|
|
763
|
+
case 'scaffold': {
|
|
764
|
+
const varsIdx = rest.indexOf('--vars');
|
|
765
|
+
const varsJson = varsIdx >= 0 ? rest[varsIdx + 1] : null;
|
|
766
|
+
cmdScaffold(rest[0], varsJson, flags);
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
case 'infer-modules':
|
|
771
|
+
cmdInferModules(rest.join(' '), flags);
|
|
772
|
+
break;
|
|
773
|
+
|
|
774
|
+
case 'validate':
|
|
775
|
+
switch (rest[0]) {
|
|
776
|
+
case 'structure': cmdValidateStructure(cwd, flags); break;
|
|
777
|
+
case 'permissions': cmdValidatePermissions(cwd, flags); break;
|
|
778
|
+
default: error('Unknown validate subcommand. Valid: structure, permissions');
|
|
779
|
+
}
|
|
780
|
+
break;
|
|
781
|
+
|
|
782
|
+
case 'commit': {
|
|
783
|
+
const filesIdx = rest.indexOf('--files');
|
|
784
|
+
const files = filesIdx >= 0 ? rest.slice(filesIdx + 1) : [];
|
|
785
|
+
cmdCommit(rest[0], files, flags);
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
default:
|
|
790
|
+
error(`Unknown command: ${command}. Run 'node fos-tools.cjs help' for usage.`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
main();
|