create-semaphor-app 0.1.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 +59 -0
- package/bin/create-semaphor-app.mjs +532 -0
- package/package.json +29 -0
- package/scripts/smoke-test.mjs +166 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# create-semaphor-app
|
|
2
|
+
|
|
3
|
+
Create a Semaphor Data App starter project.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx create-semaphor-app@latest customer-health
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The CLI scaffolds the public Semaphor Data App Starter, installs local
|
|
10
|
+
dependencies by default, and can optionally install the Semaphor Agent Plugin
|
|
11
|
+
for detected Codex and Claude Code installations.
|
|
12
|
+
|
|
13
|
+
It does not authenticate to Semaphor, write tokens, or choose a project/domain.
|
|
14
|
+
The Semaphor Agent Plugin handles OAuth, project selection, runtime token
|
|
15
|
+
minting, planning, code generation, validation, save, and publish.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx create-semaphor-app@latest <app-name>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
--no-install Skip dependency installation.
|
|
27
|
+
--package-manager <name> Use npm, pnpm, yarn, or bun. Defaults to detected npm.
|
|
28
|
+
--skip-plugin Skip Codex/Claude plugin install prompts.
|
|
29
|
+
--install-codex-plugin Install the Codex plugin without prompting.
|
|
30
|
+
--install-claude-plugin Install the Claude Code plugin without prompting.
|
|
31
|
+
--template <source> Starter source. Defaults to the public starter repo.
|
|
32
|
+
--template-ref <ref> Git branch/tag for the default starter repo. Defaults to main.
|
|
33
|
+
--yes Use noninteractive defaults; skip optional plugin installs unless explicit.
|
|
34
|
+
--help Show help.
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Local Validation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm test
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The smoke test creates temporary projects under the OS temp directory, verifies
|
|
44
|
+
the local and default starter scaffold paths, exercises fake Codex plugin
|
|
45
|
+
installation without touching the real Codex install, and removes the temporary
|
|
46
|
+
projects before exiting.
|
|
47
|
+
|
|
48
|
+
After creation:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
cd customer-health
|
|
52
|
+
npm run dev
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Then open Codex or Claude Code in the project and ask:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
@semaphor Build a Data App from my Semaphor project.
|
|
59
|
+
```
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import readline from 'node:readline/promises';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_TEMPLATE_REPO =
|
|
11
|
+
'https://github.com/semaphor-analytics/semaphor-data-app-starter.git';
|
|
12
|
+
const DEFAULT_TEMPLATE_REF = 'main';
|
|
13
|
+
const MARKETPLACE = 'semaphor-analytics/agent-plugin';
|
|
14
|
+
const PLUGIN_ID = 'semaphor@semaphor-analytics';
|
|
15
|
+
const canPrompt = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
16
|
+
|
|
17
|
+
const cwd = process.cwd();
|
|
18
|
+
|
|
19
|
+
function usage() {
|
|
20
|
+
return `Create a Semaphor Data App starter project.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
npx create-semaphor-app@latest <app-name> [options]
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--no-install Skip dependency installation.
|
|
27
|
+
--package-manager <name> Use npm, pnpm, yarn, or bun.
|
|
28
|
+
--skip-plugin Skip Codex/Claude plugin install prompts.
|
|
29
|
+
--install-codex-plugin Install the Codex plugin without prompting.
|
|
30
|
+
--install-claude-plugin Install the Claude Code plugin without prompting.
|
|
31
|
+
--template <source> Starter source. Can be a local directory or git URL.
|
|
32
|
+
--template-ref <ref> Git branch/tag for the default starter repo.
|
|
33
|
+
--yes, -y Use noninteractive defaults; skip optional plugin installs unless explicit.
|
|
34
|
+
--help, -h Show this help.
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseArgs(argv) {
|
|
39
|
+
const parsed = {
|
|
40
|
+
appName: null,
|
|
41
|
+
install: true,
|
|
42
|
+
packageManager: null,
|
|
43
|
+
skipPlugin: false,
|
|
44
|
+
installCodexPlugin: false,
|
|
45
|
+
installClaudePlugin: false,
|
|
46
|
+
template: DEFAULT_TEMPLATE_REPO,
|
|
47
|
+
templateRef: DEFAULT_TEMPLATE_REF,
|
|
48
|
+
yes: false,
|
|
49
|
+
help: false,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
53
|
+
const arg = argv[i];
|
|
54
|
+
if (arg === '--help' || arg === '-h') {
|
|
55
|
+
parsed.help = true;
|
|
56
|
+
} else if (arg === '--no-install') {
|
|
57
|
+
parsed.install = false;
|
|
58
|
+
} else if (arg === '--package-manager') {
|
|
59
|
+
parsed.packageManager = readRequiredValue(argv, ++i, arg);
|
|
60
|
+
} else if (arg.startsWith('--package-manager=')) {
|
|
61
|
+
parsed.packageManager = arg.slice('--package-manager='.length);
|
|
62
|
+
} else if (arg === '--skip-plugin') {
|
|
63
|
+
parsed.skipPlugin = true;
|
|
64
|
+
} else if (arg === '--install-codex-plugin') {
|
|
65
|
+
parsed.installCodexPlugin = true;
|
|
66
|
+
} else if (arg === '--install-claude-plugin') {
|
|
67
|
+
parsed.installClaudePlugin = true;
|
|
68
|
+
} else if (arg === '--template') {
|
|
69
|
+
parsed.template = readRequiredValue(argv, ++i, arg);
|
|
70
|
+
} else if (arg.startsWith('--template=')) {
|
|
71
|
+
parsed.template = arg.slice('--template='.length);
|
|
72
|
+
} else if (arg === '--template-ref') {
|
|
73
|
+
parsed.templateRef = readRequiredValue(argv, ++i, arg);
|
|
74
|
+
} else if (arg.startsWith('--template-ref=')) {
|
|
75
|
+
parsed.templateRef = arg.slice('--template-ref='.length);
|
|
76
|
+
} else if (arg === '--yes' || arg === '-y') {
|
|
77
|
+
parsed.yes = true;
|
|
78
|
+
} else if (arg.startsWith('-')) {
|
|
79
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
80
|
+
} else if (!parsed.appName) {
|
|
81
|
+
parsed.appName = arg;
|
|
82
|
+
} else {
|
|
83
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readRequiredValue(argv, index, optionName) {
|
|
91
|
+
const value = argv[index];
|
|
92
|
+
if (!value || value.startsWith('-')) {
|
|
93
|
+
throw new Error(`${optionName} requires a value.`);
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function slugifyPackageName(name) {
|
|
99
|
+
const base = path.basename(name).trim().toLowerCase();
|
|
100
|
+
return (
|
|
101
|
+
base
|
|
102
|
+
.replace(/[^a-z0-9._~-]+/g, '-')
|
|
103
|
+
.replace(/^-+|-+$/g, '')
|
|
104
|
+
.slice(0, 214) || 'semaphor-data-app'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isSafeRelativeTarget(name) {
|
|
109
|
+
return Boolean(name) && !path.isAbsolute(name) && !name.split(/[\\/]/).includes('..');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ensureCommand(command, label) {
|
|
113
|
+
const result = spawnSync(command, ['--version'], {
|
|
114
|
+
stdio: 'ignore',
|
|
115
|
+
shell: false,
|
|
116
|
+
});
|
|
117
|
+
if (result.error || result.status !== 0) {
|
|
118
|
+
throw new Error(`${label} is required but was not found in PATH.`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function commandExists(command) {
|
|
123
|
+
const result = spawnSync(command, ['--version'], {
|
|
124
|
+
stdio: 'ignore',
|
|
125
|
+
shell: false,
|
|
126
|
+
});
|
|
127
|
+
return !result.error && result.status === 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function run(command, args, options = {}) {
|
|
131
|
+
const result = spawnSync(command, args, {
|
|
132
|
+
cwd: options.cwd || cwd,
|
|
133
|
+
stdio: options.stdio || 'inherit',
|
|
134
|
+
shell: false,
|
|
135
|
+
env: process.env,
|
|
136
|
+
});
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function runRequired(command, args, options = {}) {
|
|
141
|
+
const result = run(command, args, options);
|
|
142
|
+
if (result.error) {
|
|
143
|
+
throw result.error;
|
|
144
|
+
}
|
|
145
|
+
if (result.status !== 0) {
|
|
146
|
+
throw new Error(`${command} ${args.join(' ')} failed.`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function directoryEntries(dir) {
|
|
151
|
+
if (!fs.existsSync(dir)) return [];
|
|
152
|
+
return fs.readdirSync(dir).filter((entry) => entry !== '.DS_Store');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function shouldSkipTemplatePath(relativePath) {
|
|
156
|
+
const parts = relativePath.split(path.sep);
|
|
157
|
+
return parts.some((part) =>
|
|
158
|
+
['.git', 'node_modules', 'dist', 'dist-ssr'].includes(part)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function copyDirectory(sourceDir, targetDir, options = {}) {
|
|
163
|
+
const copied = [];
|
|
164
|
+
const skipped = [];
|
|
165
|
+
|
|
166
|
+
function copyOne(currentSource, currentTarget, relativePath) {
|
|
167
|
+
if (relativePath && shouldSkipTemplatePath(relativePath)) {
|
|
168
|
+
skipped.push(relativePath);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const stat = fs.statSync(currentSource);
|
|
173
|
+
if (stat.isDirectory()) {
|
|
174
|
+
fs.mkdirSync(currentTarget, { recursive: true });
|
|
175
|
+
for (const entry of fs.readdirSync(currentSource)) {
|
|
176
|
+
copyOne(
|
|
177
|
+
path.join(currentSource, entry),
|
|
178
|
+
path.join(currentTarget, entry),
|
|
179
|
+
relativePath ? path.join(relativePath, entry) : entry,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (fs.existsSync(currentTarget) && options.noOverwrite) {
|
|
186
|
+
skipped.push(relativePath);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
fs.mkdirSync(path.dirname(currentTarget), { recursive: true });
|
|
191
|
+
fs.copyFileSync(currentSource, currentTarget);
|
|
192
|
+
copied.push(relativePath);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
copyOne(sourceDir, targetDir, '');
|
|
196
|
+
return { copied, skipped };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function updatePackageName(targetDir, packageName) {
|
|
200
|
+
const packageJsonPath = path.join(targetDir, 'package.json');
|
|
201
|
+
if (!fs.existsSync(packageJsonPath)) return;
|
|
202
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
203
|
+
packageJson.name = packageName;
|
|
204
|
+
fs.writeFileSync(
|
|
205
|
+
packageJsonPath,
|
|
206
|
+
`${JSON.stringify(packageJson, null, 2)}\n`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function resolvePackageManager(requested) {
|
|
211
|
+
if (requested) {
|
|
212
|
+
const normalized = requested.toLowerCase();
|
|
213
|
+
if (!['npm', 'pnpm', 'yarn', 'bun'].includes(normalized)) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`Unsupported package manager "${requested}". Use npm, pnpm, yarn, or bun.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return normalized;
|
|
219
|
+
}
|
|
220
|
+
const userAgent = process.env.npm_config_user_agent || '';
|
|
221
|
+
if (userAgent.startsWith('pnpm/')) return 'pnpm';
|
|
222
|
+
if (userAgent.startsWith('yarn/')) return 'yarn';
|
|
223
|
+
if (userAgent.startsWith('bun/')) return 'bun';
|
|
224
|
+
return 'npm';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function installArgs(packageManager) {
|
|
228
|
+
if (packageManager === 'yarn') return [];
|
|
229
|
+
return ['install'];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function promptQuestion(rl, question, defaultYes) {
|
|
233
|
+
if (!canPrompt) {
|
|
234
|
+
return Promise.resolve(false);
|
|
235
|
+
}
|
|
236
|
+
const suffix = defaultYes ? ' [Y/n] ' : ' [y/N] ';
|
|
237
|
+
return rl.question(`${question}${suffix}`).then((answer) => {
|
|
238
|
+
const normalized = answer.trim().toLowerCase();
|
|
239
|
+
if (!normalized) return defaultYes;
|
|
240
|
+
return normalized === 'y' || normalized === 'yes';
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function resolveTarget(args, rl) {
|
|
245
|
+
if (!args.appName) {
|
|
246
|
+
if (!canPrompt) {
|
|
247
|
+
throw new Error('App name is required.');
|
|
248
|
+
}
|
|
249
|
+
args.appName = await rl.question('App name: ');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const trimmed = String(args.appName || '').trim();
|
|
253
|
+
if (!trimmed) {
|
|
254
|
+
throw new Error('App name is required.');
|
|
255
|
+
}
|
|
256
|
+
if (!isSafeRelativeTarget(trimmed)) {
|
|
257
|
+
throw new Error('Use a relative app directory name without "..".');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const targetDir = path.resolve(cwd, trimmed);
|
|
261
|
+
const entries = directoryEntries(targetDir);
|
|
262
|
+
let noOverwrite = false;
|
|
263
|
+
|
|
264
|
+
if (entries.length > 0) {
|
|
265
|
+
if (args.yes) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`Directory ${targetDir} already exists and is not empty. Choose another app name.`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (!canPrompt) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`Directory ${targetDir} already exists and is not empty. Choose another app name.`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
const proceed = await promptQuestion(
|
|
276
|
+
rl,
|
|
277
|
+
`Directory ${path.relative(cwd, targetDir)} is not empty. Merge starter files without overwriting existing files?`,
|
|
278
|
+
false,
|
|
279
|
+
);
|
|
280
|
+
if (!proceed) {
|
|
281
|
+
throw new Error('Canceled.');
|
|
282
|
+
}
|
|
283
|
+
noOverwrite = true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
287
|
+
return { targetDir, packageName: slugifyPackageName(trimmed), noOverwrite };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function localTemplatePath(template) {
|
|
291
|
+
if (template.startsWith('file://')) {
|
|
292
|
+
return fileURLToPath(template);
|
|
293
|
+
}
|
|
294
|
+
const maybePath = path.resolve(cwd, template);
|
|
295
|
+
return fs.existsSync(maybePath) ? maybePath : null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function loadTemplate(template, templateRef) {
|
|
299
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'create-semaphor-app-'));
|
|
300
|
+
const localPath = localTemplatePath(template);
|
|
301
|
+
|
|
302
|
+
if (localPath) {
|
|
303
|
+
const templateDir = path.join(tempRoot, 'template');
|
|
304
|
+
copyDirectory(localPath, templateDir);
|
|
305
|
+
return { tempRoot, templateDir };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
ensureCommand('git', 'git');
|
|
309
|
+
const templateDir = path.join(tempRoot, 'template');
|
|
310
|
+
const args = ['clone', '--depth', '1'];
|
|
311
|
+
if (template === DEFAULT_TEMPLATE_REPO && templateRef) {
|
|
312
|
+
args.push('--branch', templateRef);
|
|
313
|
+
}
|
|
314
|
+
args.push(template, templateDir);
|
|
315
|
+
runRequired('git', args, { stdio: 'inherit' });
|
|
316
|
+
fs.rmSync(path.join(templateDir, '.git'), { recursive: true, force: true });
|
|
317
|
+
return { tempRoot, templateDir };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function detectAgents() {
|
|
321
|
+
return {
|
|
322
|
+
codex: commandExists('codex'),
|
|
323
|
+
claude: commandExists('claude'),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function installCodexPlugin() {
|
|
328
|
+
console.log('\nInstalling Semaphor plugin for Codex...');
|
|
329
|
+
const marketplace = run('codex', [
|
|
330
|
+
'plugin',
|
|
331
|
+
'marketplace',
|
|
332
|
+
'add',
|
|
333
|
+
MARKETPLACE,
|
|
334
|
+
]);
|
|
335
|
+
if (marketplace.error || marketplace.status !== 0) {
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
error: 'Failed to add Semaphor Codex plugin marketplace.',
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const plugin = run('codex', ['plugin', 'add', PLUGIN_ID]);
|
|
343
|
+
if (plugin.error || plugin.status !== 0) {
|
|
344
|
+
return { ok: false, error: 'Failed to install Semaphor Codex plugin.' };
|
|
345
|
+
}
|
|
346
|
+
return { ok: true };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function installClaudePlugin() {
|
|
350
|
+
console.log('\nInstalling Semaphor plugin for Claude Code...');
|
|
351
|
+
const marketplace = run('claude', [
|
|
352
|
+
'plugin',
|
|
353
|
+
'marketplace',
|
|
354
|
+
'add',
|
|
355
|
+
MARKETPLACE,
|
|
356
|
+
]);
|
|
357
|
+
if (marketplace.error || marketplace.status !== 0) {
|
|
358
|
+
return {
|
|
359
|
+
ok: false,
|
|
360
|
+
error: 'Failed to add Semaphor Claude plugin marketplace.',
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const plugin = run('claude', ['plugin', 'install', PLUGIN_ID]);
|
|
365
|
+
if (plugin.error || plugin.status !== 0) {
|
|
366
|
+
return { ok: false, error: 'Failed to install Semaphor Claude plugin.' };
|
|
367
|
+
}
|
|
368
|
+
return { ok: true };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function maybeInstallPlugins(args, rl) {
|
|
372
|
+
const detected = detectAgents();
|
|
373
|
+
const results = [];
|
|
374
|
+
|
|
375
|
+
if (args.skipPlugin) {
|
|
376
|
+
return { detected, results, skipped: true };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
console.log('\nAgent setup');
|
|
380
|
+
console.log(`Detected Codex: ${detected.codex ? 'yes' : 'no'}`);
|
|
381
|
+
console.log(`Detected Claude Code: ${detected.claude ? 'yes' : 'no'}`);
|
|
382
|
+
|
|
383
|
+
const shouldInstallCodex =
|
|
384
|
+
detected.codex &&
|
|
385
|
+
(args.installCodexPlugin ||
|
|
386
|
+
(!args.yes &&
|
|
387
|
+
canPrompt &&
|
|
388
|
+
(await promptQuestion(
|
|
389
|
+
rl,
|
|
390
|
+
'Install Semaphor plugin for Codex now?',
|
|
391
|
+
true,
|
|
392
|
+
))));
|
|
393
|
+
|
|
394
|
+
if (shouldInstallCodex) {
|
|
395
|
+
results.push({ agent: 'Codex', ...installCodexPlugin() });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const shouldInstallClaude =
|
|
399
|
+
detected.claude &&
|
|
400
|
+
(args.installClaudePlugin ||
|
|
401
|
+
(!args.yes &&
|
|
402
|
+
canPrompt &&
|
|
403
|
+
(await promptQuestion(
|
|
404
|
+
rl,
|
|
405
|
+
'Install Semaphor plugin for Claude Code now?',
|
|
406
|
+
detected.codex ? false : true,
|
|
407
|
+
))));
|
|
408
|
+
|
|
409
|
+
if (shouldInstallClaude) {
|
|
410
|
+
results.push({ agent: 'Claude Code', ...installClaudePlugin() });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return { detected, results, skipped: false };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function printNextSteps({ targetDir, packageManager, pluginSummary }) {
|
|
417
|
+
const relativeTarget = path.relative(cwd, targetDir) || '.';
|
|
418
|
+
console.log('\nNext:');
|
|
419
|
+
console.log(` cd ${relativeTarget}`);
|
|
420
|
+
console.log(` ${packageManager} run dev`);
|
|
421
|
+
|
|
422
|
+
console.log('\nThen open Codex or Claude Code in this folder and ask:');
|
|
423
|
+
console.log(' @semaphor Build a Data App from my Semaphor project.');
|
|
424
|
+
|
|
425
|
+
if (pluginSummary?.skipped) {
|
|
426
|
+
printPluginInstructions();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const failures = pluginSummary?.results?.filter((result) => !result.ok) ?? [];
|
|
431
|
+
if (failures.length > 0) {
|
|
432
|
+
console.log('\nPlugin install had issues:');
|
|
433
|
+
for (const failure of failures) {
|
|
434
|
+
console.log(` - ${failure.agent}: ${failure.error}`);
|
|
435
|
+
}
|
|
436
|
+
printPluginInstructions();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const installed = pluginSummary?.results?.filter((result) => result.ok) ?? [];
|
|
441
|
+
if (installed.length > 0) {
|
|
442
|
+
console.log('\nInstalled Semaphor plugin for:');
|
|
443
|
+
for (const result of installed) {
|
|
444
|
+
console.log(` - ${result.agent}`);
|
|
445
|
+
}
|
|
446
|
+
console.log('\nAuthenticate when your agent asks, or run:');
|
|
447
|
+
console.log(' codex mcp login semaphor');
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
printPluginInstructions();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function printPluginInstructions() {
|
|
455
|
+
console.log('\nInstall the Semaphor plugin when you are ready:');
|
|
456
|
+
console.log('\nCodex:');
|
|
457
|
+
console.log(` codex plugin marketplace add ${MARKETPLACE}`);
|
|
458
|
+
console.log(` codex plugin add ${PLUGIN_ID}`);
|
|
459
|
+
console.log(' codex mcp login semaphor');
|
|
460
|
+
console.log('\nClaude Code:');
|
|
461
|
+
console.log(` claude plugin marketplace add ${MARKETPLACE}`);
|
|
462
|
+
console.log(` claude plugin install ${PLUGIN_ID}`);
|
|
463
|
+
console.log(' Use the Claude Code MCP auth flow for the semaphor server.');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function main() {
|
|
467
|
+
let args;
|
|
468
|
+
try {
|
|
469
|
+
args = parseArgs(process.argv);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
472
|
+
console.error('');
|
|
473
|
+
console.error(usage());
|
|
474
|
+
process.exit(1);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (args.help) {
|
|
478
|
+
console.log(usage());
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const rl = readline.createInterface({
|
|
483
|
+
input: process.stdin,
|
|
484
|
+
output: process.stdout,
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
let tempRoot = null;
|
|
488
|
+
try {
|
|
489
|
+
console.log('\nSemaphor Data App\n');
|
|
490
|
+
const target = await resolveTarget(args, rl);
|
|
491
|
+
const packageManager = resolvePackageManager(args.packageManager);
|
|
492
|
+
console.log(`Creating app in ${target.targetDir}`);
|
|
493
|
+
|
|
494
|
+
const template = loadTemplate(args.template, args.templateRef);
|
|
495
|
+
tempRoot = template.tempRoot;
|
|
496
|
+
copyDirectory(template.templateDir, target.targetDir, {
|
|
497
|
+
noOverwrite: target.noOverwrite,
|
|
498
|
+
});
|
|
499
|
+
updatePackageName(target.targetDir, target.packageName);
|
|
500
|
+
console.log('✓ Created app');
|
|
501
|
+
|
|
502
|
+
if (args.install) {
|
|
503
|
+
ensureCommand(packageManager, packageManager);
|
|
504
|
+
console.log(`\nInstalling dependencies with ${packageManager}...`);
|
|
505
|
+
runRequired(packageManager, installArgs(packageManager), {
|
|
506
|
+
cwd: target.targetDir,
|
|
507
|
+
stdio: 'inherit',
|
|
508
|
+
});
|
|
509
|
+
console.log('✓ Installed dependencies');
|
|
510
|
+
} else {
|
|
511
|
+
console.log('Skipping dependency installation.');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const pluginSummary = await maybeInstallPlugins(args, rl);
|
|
515
|
+
printNextSteps({
|
|
516
|
+
targetDir: target.targetDir,
|
|
517
|
+
packageManager,
|
|
518
|
+
pluginSummary,
|
|
519
|
+
});
|
|
520
|
+
} catch (error) {
|
|
521
|
+
console.error('\nUnable to create Semaphor app.');
|
|
522
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
523
|
+
process.exitCode = 1;
|
|
524
|
+
} finally {
|
|
525
|
+
rl.close();
|
|
526
|
+
if (tempRoot) {
|
|
527
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
await main();
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-semaphor-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Create a Semaphor Data App starter project.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-semaphor-app": "bin/create-semaphor-app.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"scripts",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"check": "node --check bin/create-semaphor-app.mjs && node --check scripts/smoke-test.mjs",
|
|
16
|
+
"test": "node scripts/smoke-test.mjs"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"semaphor",
|
|
20
|
+
"data-app",
|
|
21
|
+
"react",
|
|
22
|
+
"vite",
|
|
23
|
+
"starter"
|
|
24
|
+
],
|
|
25
|
+
"license": "UNLICENSED",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const repoRoot = path.resolve(
|
|
10
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
11
|
+
'..',
|
|
12
|
+
);
|
|
13
|
+
const cliPath = path.join(repoRoot, 'bin', 'create-semaphor-app.mjs');
|
|
14
|
+
const defaultLocalStarterPath =
|
|
15
|
+
process.env.SEMAPHOR_DATA_APP_STARTER_PATH ||
|
|
16
|
+
'/Users/rohit/code/semaphor/semaphor-data-app-starter';
|
|
17
|
+
|
|
18
|
+
const tempRoots = [];
|
|
19
|
+
|
|
20
|
+
function assert(condition, message) {
|
|
21
|
+
if (!condition) {
|
|
22
|
+
throw new Error(message);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createTempRoot(label) {
|
|
27
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), `create-semaphor-app-${label}-`));
|
|
28
|
+
tempRoots.push(root);
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function run(command, args, options = {}) {
|
|
33
|
+
return spawnSync(command, args, {
|
|
34
|
+
cwd: options.cwd,
|
|
35
|
+
env: options.env || process.env,
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
stdio: options.stdio || 'pipe',
|
|
38
|
+
shell: false,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function runCli(args, options = {}) {
|
|
43
|
+
return run(process.execPath, [cliPath, ...args], options);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assertSuccess(result, label) {
|
|
47
|
+
if (result.error) {
|
|
48
|
+
throw result.error;
|
|
49
|
+
}
|
|
50
|
+
if (result.status !== 0) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`${label} failed with status ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readJson(filePath) {
|
|
58
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertScaffold(root, appName) {
|
|
62
|
+
const appRoot = path.join(root, appName);
|
|
63
|
+
const packageJson = readJson(path.join(appRoot, 'package.json'));
|
|
64
|
+
|
|
65
|
+
assert(packageJson.name === appName, `expected package name ${appName}`);
|
|
66
|
+
assert(
|
|
67
|
+
Boolean(packageJson.dependencies?.['react-semaphor']),
|
|
68
|
+
'expected react-semaphor dependency',
|
|
69
|
+
);
|
|
70
|
+
assert(fs.existsSync(path.join(appRoot, 'src', 'App.tsx')), 'expected src/App.tsx');
|
|
71
|
+
assert(!fs.existsSync(path.join(appRoot, '.git')), 'starter .git should not be copied');
|
|
72
|
+
assert(!fs.existsSync(path.join(appRoot, 'node_modules')), 'node_modules should not be copied');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createFakeCommand(binDir, name, logFile) {
|
|
76
|
+
const filePath = path.join(binDir, name);
|
|
77
|
+
fs.writeFileSync(
|
|
78
|
+
filePath,
|
|
79
|
+
`#!/usr/bin/env sh
|
|
80
|
+
echo "$0 $@" >> "${logFile}"
|
|
81
|
+
if [ "$1" = "--version" ]; then echo "${name} test"; fi
|
|
82
|
+
exit 0
|
|
83
|
+
`,
|
|
84
|
+
);
|
|
85
|
+
fs.chmodSync(filePath, 0o755);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function main() {
|
|
89
|
+
try {
|
|
90
|
+
console.log('create-semaphor-app smoke test');
|
|
91
|
+
|
|
92
|
+
assert(fs.existsSync(defaultLocalStarterPath), `starter not found: ${defaultLocalStarterPath}`);
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
const root = createTempRoot('local-');
|
|
96
|
+
const result = runCli([
|
|
97
|
+
'customer-health',
|
|
98
|
+
'--template',
|
|
99
|
+
defaultLocalStarterPath,
|
|
100
|
+
'--no-install',
|
|
101
|
+
'--skip-plugin',
|
|
102
|
+
'--yes',
|
|
103
|
+
], { cwd: root });
|
|
104
|
+
assertSuccess(result, 'local starter scaffold');
|
|
105
|
+
assertScaffold(root, 'customer-health');
|
|
106
|
+
assert(result.stdout.includes('Skipping dependency installation.'), 'expected no-install output');
|
|
107
|
+
console.log('✓ local starter scaffold');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
const root = createTempRoot('github-');
|
|
112
|
+
const result = runCli([
|
|
113
|
+
'github-template-app',
|
|
114
|
+
'--no-install',
|
|
115
|
+
'--skip-plugin',
|
|
116
|
+
'--yes',
|
|
117
|
+
], { cwd: root });
|
|
118
|
+
assertSuccess(result, 'default GitHub starter scaffold');
|
|
119
|
+
assertScaffold(root, 'github-template-app');
|
|
120
|
+
console.log('✓ default GitHub starter scaffold');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
const root = createTempRoot('fake-codex-');
|
|
125
|
+
const binDir = path.join(root, 'bin');
|
|
126
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
127
|
+
const codexLog = path.join(root, 'codex.log');
|
|
128
|
+
createFakeCommand(binDir, 'codex', codexLog);
|
|
129
|
+
|
|
130
|
+
const result = runCli([
|
|
131
|
+
'plugin-test-app',
|
|
132
|
+
'--template',
|
|
133
|
+
defaultLocalStarterPath,
|
|
134
|
+
'--no-install',
|
|
135
|
+
'--install-codex-plugin',
|
|
136
|
+
], {
|
|
137
|
+
cwd: root,
|
|
138
|
+
env: {
|
|
139
|
+
...process.env,
|
|
140
|
+
PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
assertSuccess(result, 'fake Codex plugin install');
|
|
144
|
+
assertScaffold(root, 'plugin-test-app');
|
|
145
|
+
const log = fs.readFileSync(codexLog, 'utf8');
|
|
146
|
+
assert(
|
|
147
|
+
log.includes('codex plugin marketplace add semaphor-analytics/agent-plugin'),
|
|
148
|
+
'expected codex marketplace add command',
|
|
149
|
+
);
|
|
150
|
+
assert(
|
|
151
|
+
log.includes('codex plugin add semaphor@semaphor-analytics'),
|
|
152
|
+
'expected codex plugin add command',
|
|
153
|
+
);
|
|
154
|
+
console.log('✓ fake Codex plugin install path');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
console.log('✓ cleaned up temp projects');
|
|
158
|
+
} finally {
|
|
159
|
+
for (const root of tempRoots.reverse()) {
|
|
160
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await main();
|
|
166
|
+
|