piezas 0.2.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 +309 -0
- package/package.json +27 -0
- package/src/index.js +2000 -0
- package/src/templates/piezas-instructions.md +674 -0
- package/src/templates/piezas-spec.md +164 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,2000 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
statSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'fs';
|
|
14
|
+
import { execSync, spawn } from 'child_process';
|
|
15
|
+
import { homedir } from 'os';
|
|
16
|
+
import { basename, dirname, extname, join, relative, resolve } from 'path';
|
|
17
|
+
import { createInterface } from 'readline';
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
// Human-facing progress output. init --json redirects this to stderr so stdout
|
|
23
|
+
// stays machine-readable.
|
|
24
|
+
let emit = (message) => console.log(message);
|
|
25
|
+
function log(message) {
|
|
26
|
+
emit(message);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_API_BASE_URL = 'https://api.piezas.ai';
|
|
30
|
+
// The gateway routes admin-api (the control plane) under {apiBase}/admin —
|
|
31
|
+
// the same base the SDK instructions use for adminUrl.
|
|
32
|
+
const ADMIN_PATH_PREFIX = '/admin';
|
|
33
|
+
const HTTP_TIMEOUT_MS = 10_000;
|
|
34
|
+
const API_KEY_FORMAT = /^sk_(live|test)_[A-Za-z0-9]{8,}$/;
|
|
35
|
+
const ENV_KEY_LINE = /^\s*(?:export\s+)?PIEZAS_API_KEY\s*=\s*(.*)$/;
|
|
36
|
+
const WAIT_FILE_CANDIDATES = ['.env', '.env.download', 'env.txt'];
|
|
37
|
+
const DEFAULT_WAIT_TIMEOUT_SECONDS = 300;
|
|
38
|
+
const DEFAULT_WAIT_INTERVAL_MS = 2000;
|
|
39
|
+
const KEY_PROMPT = 'Paste your Piezas API key (dashboard -> API Keys), or press Enter to skip: ';
|
|
40
|
+
|
|
41
|
+
const PIEZAS_CONTEXT_START = '<!-- PIEZAS-CONTEXT:START -->';
|
|
42
|
+
const PIEZAS_CONTEXT_END = '<!-- PIEZAS-CONTEXT:END -->';
|
|
43
|
+
const PIEZAS_SPEC_START = '<!-- PIEZAS-SPEC:START -->';
|
|
44
|
+
const PIEZAS_INSTRUCTIONS_BODY = readFileSync(
|
|
45
|
+
resolve(__dirname, 'templates', 'piezas-instructions.md'),
|
|
46
|
+
'utf-8',
|
|
47
|
+
).trim();
|
|
48
|
+
const PIEZAS_INSTRUCTIONS = `${PIEZAS_CONTEXT_START}\n${PIEZAS_INSTRUCTIONS_BODY}\n${PIEZAS_CONTEXT_END}\n`;
|
|
49
|
+
const PIEZAS_SPEC_INSTRUCTIONS = `${readFileSync(
|
|
50
|
+
resolve(__dirname, 'templates', 'piezas-spec.md'),
|
|
51
|
+
'utf-8',
|
|
52
|
+
).trim()}\n`;
|
|
53
|
+
|
|
54
|
+
const DEFAULT_EXCLUDED_DIRS = new Set([
|
|
55
|
+
'.git',
|
|
56
|
+
'.next',
|
|
57
|
+
'.turbo',
|
|
58
|
+
'build',
|
|
59
|
+
'coverage',
|
|
60
|
+
'dist',
|
|
61
|
+
'node_modules',
|
|
62
|
+
'out',
|
|
63
|
+
]);
|
|
64
|
+
const TEXT_EXTENSIONS = new Set([
|
|
65
|
+
'.cjs',
|
|
66
|
+
'.css',
|
|
67
|
+
'.cts',
|
|
68
|
+
'.env',
|
|
69
|
+
'.js',
|
|
70
|
+
'.json',
|
|
71
|
+
'.jsx',
|
|
72
|
+
'.md',
|
|
73
|
+
'.mdc',
|
|
74
|
+
'.mjs',
|
|
75
|
+
'.mts',
|
|
76
|
+
'.ts',
|
|
77
|
+
'.tsx',
|
|
78
|
+
'.yml',
|
|
79
|
+
'.yaml',
|
|
80
|
+
]);
|
|
81
|
+
const CODE_EXTENSIONS = new Set(['.cjs', '.cts', '.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx']);
|
|
82
|
+
const LOCAL_DB_PACKAGES = [
|
|
83
|
+
'@prisma/client',
|
|
84
|
+
'better-sqlite3',
|
|
85
|
+
'drizzle-orm',
|
|
86
|
+
'knex',
|
|
87
|
+
'mongoose',
|
|
88
|
+
'mysql2',
|
|
89
|
+
'pg',
|
|
90
|
+
'prisma',
|
|
91
|
+
'sequelize',
|
|
92
|
+
'sqlite',
|
|
93
|
+
'sqlite3',
|
|
94
|
+
'typeorm',
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
export const DEFAULT_DEPLOYMENT_MODE = 'next-bff';
|
|
98
|
+
|
|
99
|
+
const MCP_ROUTE_PATH = 'app/api/piezas/mcp/route.ts';
|
|
100
|
+
const NEXT_MCP_ROUTE_DEPLOYMENT_MODES = new Set(['next-bff', 'server-runtime']);
|
|
101
|
+
|
|
102
|
+
export const RECIPE_PRESETS = {
|
|
103
|
+
'booking-site': {
|
|
104
|
+
slug: 'booking-site',
|
|
105
|
+
label: 'Public booking and appointment system',
|
|
106
|
+
owns: {
|
|
107
|
+
piezas: [
|
|
108
|
+
'tenantUsers',
|
|
109
|
+
'publicSessions',
|
|
110
|
+
'organizerProfiles',
|
|
111
|
+
'eventTypes',
|
|
112
|
+
'availabilityRules',
|
|
113
|
+
'bookings',
|
|
114
|
+
'inviteeAnswers',
|
|
115
|
+
'calendarConnections',
|
|
116
|
+
'meetingProviderConnections',
|
|
117
|
+
'bookingNotifications',
|
|
118
|
+
'auditEvents',
|
|
119
|
+
],
|
|
120
|
+
app: ['bookingPageUi', 'adminScreens', 'timezonePresentation', 'routing'],
|
|
121
|
+
},
|
|
122
|
+
services: ['admin', 'entities', 'calendar', 'integrations', 'notifications', 'forms', 'workflow'],
|
|
123
|
+
setupOrder: [
|
|
124
|
+
'create tenant invite flow',
|
|
125
|
+
'connect calendar and meeting providers through Piezas Integrations',
|
|
126
|
+
'define organizer/event-type records',
|
|
127
|
+
'create availability rules',
|
|
128
|
+
'create public sessions for browser-safe booking flows',
|
|
129
|
+
'create bookings with server-side slot revalidation',
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
'crm-project-finance': {
|
|
133
|
+
slug: 'crm-project-finance',
|
|
134
|
+
label: 'CRM, project tracking, and basic finance operations',
|
|
135
|
+
owns: {
|
|
136
|
+
piezas: [
|
|
137
|
+
'contacts',
|
|
138
|
+
'companies',
|
|
139
|
+
'deals',
|
|
140
|
+
'projects',
|
|
141
|
+
'tasks',
|
|
142
|
+
'invoices',
|
|
143
|
+
'receipts',
|
|
144
|
+
'bills',
|
|
145
|
+
'bankTransactions',
|
|
146
|
+
'accountingAccounts',
|
|
147
|
+
'ledgerEntries',
|
|
148
|
+
'reconciliationLinks',
|
|
149
|
+
'documentExtractionJobs',
|
|
150
|
+
'signatureRequests',
|
|
151
|
+
'documents',
|
|
152
|
+
'auditEvents',
|
|
153
|
+
],
|
|
154
|
+
app: ['workspaceUi', 'recordLayouts', 'approvalFlows', 'reportViews'],
|
|
155
|
+
},
|
|
156
|
+
services: ['entities', 'pipeline', 'tasks', 'documents', 'pricing', 'reporting', 'workflow', 'integrations'],
|
|
157
|
+
setupOrder: [
|
|
158
|
+
'install CRM/project/finance entity patterns',
|
|
159
|
+
'define deal and project pipelines',
|
|
160
|
+
'store invoices and receipts as entity records linked to documents',
|
|
161
|
+
'store bills, bank transactions, finance accounts, and reconciliation links as entity records',
|
|
162
|
+
'store ledger entries and reconciliation links as Piezas entity records using SDK invariant helpers',
|
|
163
|
+
'create document extraction jobs in Piezas Documents before invoking OCR integration actions',
|
|
164
|
+
'create signature requests in Piezas Documents before invoking e-signature provider actions',
|
|
165
|
+
'use integration actions for OCR, accounting exports, and payment-provider references',
|
|
166
|
+
'use workflow sync jobs for imports, reconciliation, reminders, external accounting sync, and reports',
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
'client-services-os': {
|
|
170
|
+
slug: 'client-services-os',
|
|
171
|
+
label: 'Client services operating system',
|
|
172
|
+
owns: {
|
|
173
|
+
piezas: [
|
|
174
|
+
'clients',
|
|
175
|
+
'projects',
|
|
176
|
+
'cases',
|
|
177
|
+
'tasks',
|
|
178
|
+
'documents',
|
|
179
|
+
'discussions',
|
|
180
|
+
'appointments',
|
|
181
|
+
'forms',
|
|
182
|
+
'signatureRequests',
|
|
183
|
+
'knowledgeBase',
|
|
184
|
+
'auditEvents',
|
|
185
|
+
],
|
|
186
|
+
app: ['portalUi', 'clientDashboard', 'staffWorkspace', 'routing'],
|
|
187
|
+
},
|
|
188
|
+
services: ['admin', 'entities', 'tasks', 'calendar', 'documents', 'discussion', 'forms', 'knowledge-base', 'workflow'],
|
|
189
|
+
setupOrder: [
|
|
190
|
+
'create invite-only tenant/user access',
|
|
191
|
+
'define client, project, and case records',
|
|
192
|
+
'link tasks, discussions, documents, forms, and bookings back to client records',
|
|
193
|
+
'store signature-request state as Piezas records and use integrations for external signature providers',
|
|
194
|
+
'use public sessions for client-facing portal actions',
|
|
195
|
+
'use audit events for client-visible and staff-visible state changes',
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function normalizeRecipeList(value) {
|
|
201
|
+
const values = Array.isArray(value) ? value : value ? [value] : [];
|
|
202
|
+
return values
|
|
203
|
+
.flatMap((item) => String(item).split(','))
|
|
204
|
+
.map((item) => item.trim())
|
|
205
|
+
.filter(Boolean);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function recipeManifest(slug) {
|
|
209
|
+
return RECIPE_PRESETS[slug] ?? {
|
|
210
|
+
slug,
|
|
211
|
+
label: slug,
|
|
212
|
+
owns: { piezas: [], app: [] },
|
|
213
|
+
services: [],
|
|
214
|
+
setupOrder: [],
|
|
215
|
+
notes: ['Custom recipe placeholder. Extend this manifest before implementation.'],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function supportsMcpRoute(deploymentMode) {
|
|
220
|
+
return NEXT_MCP_ROUTE_DEPLOYMENT_MODES.has(deploymentMode);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function mcpManifest(options = {}) {
|
|
224
|
+
const deploymentMode = options.deploymentMode || DEFAULT_DEPLOYMENT_MODE;
|
|
225
|
+
const requested = Boolean(options.mcp);
|
|
226
|
+
const enabled = requested && supportsMcpRoute(deploymentMode);
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
requested,
|
|
230
|
+
enabled,
|
|
231
|
+
sdkExport: 'piezasMcp',
|
|
232
|
+
routePath: enabled ? MCP_ROUTE_PATH : null,
|
|
233
|
+
availableTools: ['entity-records', 'pipeline', 'tasks'],
|
|
234
|
+
appMustProvide: ['sessionAuth', 'tenantContext', 'userContext', 'routeAccessControl'],
|
|
235
|
+
note: enabled
|
|
236
|
+
? 'Protect the MCP route with app auth before exposing it to users or agents.'
|
|
237
|
+
: 'Use `npx piezas init --mcp --mode next-bff` or mount `piezasMcp` in a server runtime when agent tool access is needed.',
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function buildDefaultManifest(options = {}) {
|
|
242
|
+
const deploymentMode = options.deploymentMode || DEFAULT_DEPLOYMENT_MODE;
|
|
243
|
+
const recipes = normalizeRecipeList(options.recipes ?? options.recipe)
|
|
244
|
+
.map(recipeManifest);
|
|
245
|
+
return {
|
|
246
|
+
$schema: 'https://piezas.ai/schemas/piezas-manifest.schema.json',
|
|
247
|
+
schemaVersion: 1,
|
|
248
|
+
generatedBy: 'piezas',
|
|
249
|
+
appName: options.appName ?? null,
|
|
250
|
+
apiBaseUrl: options.apiBaseUrl || DEFAULT_API_BASE_URL,
|
|
251
|
+
productType: options.productType || 'custom-app',
|
|
252
|
+
deploymentMode,
|
|
253
|
+
piezasOwns: [
|
|
254
|
+
'businessRecords',
|
|
255
|
+
'crmRecords',
|
|
256
|
+
'accountingRecords',
|
|
257
|
+
'ledgerEntryRecords',
|
|
258
|
+
'reconciliationRecords',
|
|
259
|
+
'documentExtractionRecords',
|
|
260
|
+
'signatureRequestRecords',
|
|
261
|
+
'financeAccounts',
|
|
262
|
+
'bills',
|
|
263
|
+
'bankTransactions',
|
|
264
|
+
'calendarAvailability',
|
|
265
|
+
'bookings',
|
|
266
|
+
'integrationConnections',
|
|
267
|
+
'integrationAppRegistry',
|
|
268
|
+
'integrationCredentials',
|
|
269
|
+
'integrationAppPolicies',
|
|
270
|
+
'integrationGrants',
|
|
271
|
+
'integrationTokenRefresh',
|
|
272
|
+
'notifications',
|
|
273
|
+
'workflowState',
|
|
274
|
+
'durableJobs',
|
|
275
|
+
'syncJobs',
|
|
276
|
+
'jobLockState',
|
|
277
|
+
'mcpToolDiscovery',
|
|
278
|
+
'publicSessions',
|
|
279
|
+
'auditEvents',
|
|
280
|
+
'accessLogs',
|
|
281
|
+
'filesAndDocuments',
|
|
282
|
+
'reportingData',
|
|
283
|
+
'searchImportExport',
|
|
284
|
+
],
|
|
285
|
+
appOwns: [
|
|
286
|
+
'ui',
|
|
287
|
+
'routing',
|
|
288
|
+
'sessionGlue',
|
|
289
|
+
'mcpRouteAccessControl',
|
|
290
|
+
'workflowOrchestration',
|
|
291
|
+
'viewState',
|
|
292
|
+
],
|
|
293
|
+
serverOnlySecrets: [
|
|
294
|
+
'PIEZAS_API_KEY',
|
|
295
|
+
'NEXTAUTH_SECRET',
|
|
296
|
+
'AUTH_SECRET',
|
|
297
|
+
'SESSION_SECRET',
|
|
298
|
+
'ADMIN_TOKEN',
|
|
299
|
+
'ADMIN_PASS',
|
|
300
|
+
'GOOGLE_CLIENT_SECRET',
|
|
301
|
+
'ZOOM_CLIENT_SECRET',
|
|
302
|
+
],
|
|
303
|
+
integrationPolicy: {
|
|
304
|
+
piezasOwns: [
|
|
305
|
+
'providerClientConfig',
|
|
306
|
+
'appScopedProviderClientConfig',
|
|
307
|
+
'appAllowedOrigins',
|
|
308
|
+
'appAllowedRedirectUris',
|
|
309
|
+
'appConnectorPurposePolicy',
|
|
310
|
+
'providerActionCatalog',
|
|
311
|
+
'oauthCallbacks',
|
|
312
|
+
'encryptedAccessTokens',
|
|
313
|
+
'encryptedRefreshTokens',
|
|
314
|
+
'tokenRefresh',
|
|
315
|
+
'syncCursors',
|
|
316
|
+
'connectionGrants',
|
|
317
|
+
'documentExtractionJobs',
|
|
318
|
+
'signatureRequests',
|
|
319
|
+
],
|
|
320
|
+
appStoresOnly: ['appIds', 'connectionIds', 'grantIds', 'externalRecordRefs'],
|
|
321
|
+
appScopedOAuth:
|
|
322
|
+
'Create a Piezas tenant app for each generated app/domain/use case, save provider client config with appId and purpose, and pass appId/purpose on OAuth start and connection lookup.',
|
|
323
|
+
payments: 'Use provider integrations for payment links or references; Piezas does not process money.',
|
|
324
|
+
},
|
|
325
|
+
recommendedPatterns: [
|
|
326
|
+
'relationships',
|
|
327
|
+
'ledger',
|
|
328
|
+
'accounting-postings',
|
|
329
|
+
'reconciliation',
|
|
330
|
+
'thin-server-adapter',
|
|
331
|
+
'sdk-mcp-route',
|
|
332
|
+
'public-sessions',
|
|
333
|
+
'durable-jobs',
|
|
334
|
+
'sync-jobs',
|
|
335
|
+
],
|
|
336
|
+
mcp: mcpManifest({ deploymentMode, mcp: options.mcp }),
|
|
337
|
+
publicRoutes: [],
|
|
338
|
+
recipes,
|
|
339
|
+
notes: [
|
|
340
|
+
'Future recipes should extend this manifest instead of replacing it.',
|
|
341
|
+
'Run `npx piezas doctor` after AI-generated changes.',
|
|
342
|
+
],
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Flags that never take a value — they must not swallow a following
|
|
347
|
+
// positional (e.g. `piezas init --wait mydir` targets mydir/).
|
|
348
|
+
const BOOLEAN_FLAGS = new Set([
|
|
349
|
+
'fromLogin',
|
|
350
|
+
'json',
|
|
351
|
+
'mcp',
|
|
352
|
+
'noDeps',
|
|
353
|
+
'skipDeps',
|
|
354
|
+
'skipInstall',
|
|
355
|
+
'strict',
|
|
356
|
+
'wait',
|
|
357
|
+
'yes',
|
|
358
|
+
]);
|
|
359
|
+
|
|
360
|
+
export function parseCliArgs(args) {
|
|
361
|
+
const flags = {};
|
|
362
|
+
const positional = [];
|
|
363
|
+
|
|
364
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
365
|
+
const arg = args[i];
|
|
366
|
+
if (!arg.startsWith('--')) {
|
|
367
|
+
positional.push(arg);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const [rawKey, inlineValue] = arg.slice(2).split('=');
|
|
372
|
+
const key = rawKey.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
|
373
|
+
if (inlineValue !== undefined) {
|
|
374
|
+
flags[key] = appendFlagValue(flags[key], inlineValue);
|
|
375
|
+
} else if (!BOOLEAN_FLAGS.has(key) && args[i + 1] && !args[i + 1].startsWith('--')) {
|
|
376
|
+
flags[key] = appendFlagValue(flags[key], args[i + 1]);
|
|
377
|
+
i += 1;
|
|
378
|
+
} else {
|
|
379
|
+
flags[key] = appendFlagValue(flags[key], true);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return { flags, positional };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function appendFlagValue(existing, value) {
|
|
387
|
+
if (existing === undefined) return value;
|
|
388
|
+
return Array.isArray(existing) ? [...existing, value] : [existing, value];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// spliceMarkerBlock replaces the START..END block inside `existing` with the
|
|
392
|
+
// block from `content`, preserving any user content around the markers. Falls
|
|
393
|
+
// back to a full replace when the block cannot be located on both sides.
|
|
394
|
+
function spliceMarkerBlock(existing, content, startMarker, endMarker) {
|
|
395
|
+
if (!endMarker) return content;
|
|
396
|
+
const start = existing.indexOf(startMarker);
|
|
397
|
+
const end = existing.indexOf(endMarker, start);
|
|
398
|
+
const newStart = content.indexOf(startMarker);
|
|
399
|
+
const newEnd = content.indexOf(endMarker, newStart);
|
|
400
|
+
if (start < 0 || end < 0 || newStart < 0 || newEnd < 0) return content;
|
|
401
|
+
return (
|
|
402
|
+
existing.slice(0, start) +
|
|
403
|
+
content.slice(newStart, newEnd + endMarker.length) +
|
|
404
|
+
existing.slice(end + endMarker.length)
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function writeIfNew(filePath, content, label, options = {}) {
|
|
409
|
+
const startMarker = options.startMarker || PIEZAS_CONTEXT_START;
|
|
410
|
+
const endMarker = options.endMarker ?? (startMarker === PIEZAS_CONTEXT_START ? PIEZAS_CONTEXT_END : undefined);
|
|
411
|
+
const appendIfNoMarker = options.appendIfNoMarker ?? true;
|
|
412
|
+
|
|
413
|
+
if (existsSync(filePath)) {
|
|
414
|
+
const existing = readFileSync(filePath, 'utf-8');
|
|
415
|
+
if (existing === content) {
|
|
416
|
+
log(` ${label} is up to date - skipping`);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (existing.includes(startMarker)) {
|
|
420
|
+
const updated = spliceMarkerBlock(existing, content, startMarker, endMarker);
|
|
421
|
+
if (updated === existing) {
|
|
422
|
+
log(` ${label} is up to date - skipping`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
writeFileSync(filePath, updated);
|
|
426
|
+
log(` Updated ${label}`);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (!appendIfNoMarker) {
|
|
430
|
+
log(` ${label} exists - skipping`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
writeFileSync(filePath, `${existing.trimEnd()}\n\n${content}`);
|
|
434
|
+
log(` Updated ${label}`);
|
|
435
|
+
} else {
|
|
436
|
+
writeFileSync(filePath, content);
|
|
437
|
+
log(` Created ${label}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function writeFileIfMissing(filePath, content, label) {
|
|
442
|
+
if (existsSync(filePath)) {
|
|
443
|
+
const existing = readFileSync(filePath, 'utf-8');
|
|
444
|
+
if (existing === content) {
|
|
445
|
+
log(` ${label} is up to date - skipping`);
|
|
446
|
+
} else {
|
|
447
|
+
log(` ${label} exists - skipping`);
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
453
|
+
writeFileSync(filePath, content);
|
|
454
|
+
log(` Created ${label}`);
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// upsertManifest creates piezas.manifest.json when missing, or extends an
|
|
459
|
+
// existing one with app metadata (appName, apiBaseUrl) without touching any
|
|
460
|
+
// other field the project may have customized.
|
|
461
|
+
function upsertManifest(manifestPath, buildOptions) {
|
|
462
|
+
if (!existsSync(manifestPath)) {
|
|
463
|
+
const manifest = buildDefaultManifest(buildOptions);
|
|
464
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
465
|
+
log(' Created piezas.manifest.json');
|
|
466
|
+
return manifest;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let manifest;
|
|
470
|
+
try {
|
|
471
|
+
manifest = readJsonFile(manifestPath);
|
|
472
|
+
} catch {
|
|
473
|
+
log(' piezas.manifest.json is not valid JSON - leaving it untouched');
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let changed = false;
|
|
478
|
+
if (buildOptions.appName && manifest.appName !== buildOptions.appName) {
|
|
479
|
+
manifest.appName = buildOptions.appName;
|
|
480
|
+
changed = true;
|
|
481
|
+
}
|
|
482
|
+
if (manifest.appName === undefined) {
|
|
483
|
+
manifest.appName = null;
|
|
484
|
+
changed = true;
|
|
485
|
+
}
|
|
486
|
+
if (manifest.apiBaseUrl === undefined) {
|
|
487
|
+
manifest.apiBaseUrl = DEFAULT_API_BASE_URL;
|
|
488
|
+
changed = true;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (changed) {
|
|
492
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
493
|
+
log(' Updated piezas.manifest.json (app metadata)');
|
|
494
|
+
} else {
|
|
495
|
+
log(' piezas.manifest.json is up to date - skipping');
|
|
496
|
+
}
|
|
497
|
+
return manifest;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function mcpRouteContent() {
|
|
501
|
+
return `import { piezasMcp } from '@piezas/sdk';
|
|
502
|
+
|
|
503
|
+
export const runtime = 'nodejs';
|
|
504
|
+
export const dynamic = 'force-dynamic';
|
|
505
|
+
|
|
506
|
+
const handler = piezasMcp({
|
|
507
|
+
entitiesUrl: process.env.PIEZAS_ENTITIES_URL || 'https://api.piezas.ai/entities',
|
|
508
|
+
pipelineUrl: process.env.PIEZAS_PIPELINE_URL || 'https://api.piezas.ai/pipeline',
|
|
509
|
+
tasksUrl: process.env.PIEZAS_TASKS_URL || 'https://api.piezas.ai/tasks',
|
|
510
|
+
name: process.env.MCP_SERVER_NAME || 'piezas-app',
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
function requireMcpAccess(req: Request) {
|
|
514
|
+
const hasAppSession = Boolean(req.headers.get('authorization') || req.headers.get('cookie'));
|
|
515
|
+
const hasTenantContext = Boolean(req.headers.get('x-tenant-id') && req.headers.get('x-user-id'));
|
|
516
|
+
if (hasAppSession && hasTenantContext) return null;
|
|
517
|
+
|
|
518
|
+
return Response.json(
|
|
519
|
+
{ error: 'MCP route requires app auth plus X-Tenant-Id and X-User-Id.' },
|
|
520
|
+
{ status: 401 },
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export async function POST(req: Request) {
|
|
525
|
+
// Replace this guard with your app session/auth check before exposing MCP publicly.
|
|
526
|
+
const denied = requireMcpAccess(req);
|
|
527
|
+
if (denied) return denied;
|
|
528
|
+
|
|
529
|
+
return handler(req);
|
|
530
|
+
}
|
|
531
|
+
`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function scaffoldMcpRoute(projectDir, deploymentMode, requested) {
|
|
535
|
+
if (!requested) {
|
|
536
|
+
return { enabled: false, routePath: null };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (!supportsMcpRoute(deploymentMode)) {
|
|
540
|
+
if (deploymentMode === 'static') {
|
|
541
|
+
log(' Skipped MCP route: static deployment mode cannot host server routes.');
|
|
542
|
+
} else {
|
|
543
|
+
log(' Skipped MCP route: mount `piezasMcp` in your server framework for this deployment mode.');
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
enabled: false,
|
|
547
|
+
routePath: null,
|
|
548
|
+
skippedReason: deploymentMode === 'static' ? 'static-deployment-mode' : 'manual-server-framework',
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
writeFileIfMissing(
|
|
553
|
+
resolve(projectDir, MCP_ROUTE_PATH),
|
|
554
|
+
mcpRouteContent(),
|
|
555
|
+
MCP_ROUTE_PATH,
|
|
556
|
+
);
|
|
557
|
+
return { enabled: true, routePath: MCP_ROUTE_PATH };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function packageNameFromDir(projectDir) {
|
|
561
|
+
const name = basename(projectDir)
|
|
562
|
+
.toLowerCase()
|
|
563
|
+
.replace(/^@/, '')
|
|
564
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
565
|
+
.replace(/^-+|-+$/g, '');
|
|
566
|
+
return name || 'piezas-app';
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function ensurePackageJson(projectDir) {
|
|
570
|
+
const pkgJsonPath = resolve(projectDir, 'package.json');
|
|
571
|
+
if (existsSync(pkgJsonPath)) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const pkg = {
|
|
576
|
+
name: packageNameFromDir(projectDir),
|
|
577
|
+
version: '0.1.0',
|
|
578
|
+
private: true,
|
|
579
|
+
type: 'module',
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
writeFileSync(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
583
|
+
log(' Created package.json');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function stripQuotes(value) {
|
|
587
|
+
const match = /^(['"])(.*)\1$/.exec(value);
|
|
588
|
+
return match ? match[2] : value;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// readEnvKeyValue returns the PIEZAS_API_KEY value from a dotenv-style file,
|
|
592
|
+
// or undefined when the file or the line is absent.
|
|
593
|
+
function readEnvKeyValue(filePath) {
|
|
594
|
+
if (!existsSync(filePath)) return undefined;
|
|
595
|
+
for (const line of readFileSync(filePath, 'utf-8').split(/\r?\n/)) {
|
|
596
|
+
const match = ENV_KEY_LINE.exec(line);
|
|
597
|
+
if (match) return stripQuotes(match[1].trim());
|
|
598
|
+
}
|
|
599
|
+
return undefined;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// writeEnvKey merges PIEZAS_API_KEY=<key> into the project .env: creates the
|
|
603
|
+
// file, appends the line, or replaces ONLY the existing PIEZAS_API_KEY line.
|
|
604
|
+
// Every other byte — including CRLF/LF line endings — is preserved.
|
|
605
|
+
function writeEnvKey(envPath, key) {
|
|
606
|
+
if (!existsSync(envPath)) {
|
|
607
|
+
writeFileSync(envPath, `PIEZAS_API_KEY=${key}\n`);
|
|
608
|
+
return 'created';
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
612
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
613
|
+
const parts = content.split(/(\r?\n)/);
|
|
614
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
615
|
+
const match = ENV_KEY_LINE.exec(parts[i]);
|
|
616
|
+
if (!match) continue;
|
|
617
|
+
if (stripQuotes(match[1].trim()) === key) return 'kept';
|
|
618
|
+
parts[i] = `PIEZAS_API_KEY=${key}`;
|
|
619
|
+
writeFileSync(envPath, parts.join(''));
|
|
620
|
+
return 'replaced';
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const needsNewline = content.length > 0 && !content.endsWith('\n');
|
|
624
|
+
writeFileSync(envPath, `${content}${needsNewline ? eol : ''}PIEZAS_API_KEY=${key}${eol}`);
|
|
625
|
+
return 'appended';
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const GITIGNORE_ENV_PATTERNS = new Set(['.env', '/.env', '.env*', '*.env', '**/.env']);
|
|
629
|
+
|
|
630
|
+
// ensureGitignore makes sure .env is git-ignored. Respects existing patterns
|
|
631
|
+
// (.env, .env*, *.env, ...) and appends a "# piezas" section only when needed.
|
|
632
|
+
function ensureGitignore(projectDir) {
|
|
633
|
+
const gitignorePath = resolve(projectDir, '.gitignore');
|
|
634
|
+
if (!existsSync(gitignorePath)) {
|
|
635
|
+
writeFileSync(gitignorePath, '# piezas\n.env\n');
|
|
636
|
+
log(' Created .gitignore (.env ignored)');
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const content = readFileSync(gitignorePath, 'utf-8');
|
|
641
|
+
const covered = content
|
|
642
|
+
.split(/\r?\n/)
|
|
643
|
+
.some((line) => GITIGNORE_ENV_PATTERNS.has(line.trim()));
|
|
644
|
+
if (covered) {
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
649
|
+
let updated = content;
|
|
650
|
+
if (updated.length > 0 && !updated.endsWith('\n')) updated += eol;
|
|
651
|
+
if (updated.length > 0) updated += eol;
|
|
652
|
+
updated += `# piezas${eol}.env${eol}`;
|
|
653
|
+
writeFileSync(gitignorePath, updated);
|
|
654
|
+
log(' Updated .gitignore (.env ignored)');
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function promptViaTerminal(question) {
|
|
659
|
+
return new Promise((resolvePrompt) => {
|
|
660
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
661
|
+
rl.question(question, (answer) => {
|
|
662
|
+
rl.close();
|
|
663
|
+
resolvePrompt(answer);
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// waitForEnvKey polls the project dir for a dropped-in .env (or the names a
|
|
669
|
+
// browser may give the dashboard download) containing PIEZAS_API_KEY.
|
|
670
|
+
async function waitForEnvKey(projectDir, timeoutSeconds, intervalMs) {
|
|
671
|
+
log(` Waiting for your API key: drop a ${WAIT_FILE_CANDIDATES.join(' or ')} file`);
|
|
672
|
+
log(` containing PIEZAS_API_KEY into ${projectDir}`);
|
|
673
|
+
log(` (dashboard -> API Keys -> "Download .env"). Checking every ${Math.round(intervalMs / 100) / 10}s, timeout ${timeoutSeconds}s.`);
|
|
674
|
+
|
|
675
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
676
|
+
for (;;) {
|
|
677
|
+
for (const name of WAIT_FILE_CANDIDATES) {
|
|
678
|
+
const value = readEnvKeyValue(resolve(projectDir, name));
|
|
679
|
+
if (value) {
|
|
680
|
+
log(` Found PIEZAS_API_KEY in ${name}`);
|
|
681
|
+
return { value, file: name };
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (Date.now() >= deadline) {
|
|
685
|
+
log(` No key file appeared within ${timeoutSeconds}s - continuing without a key.`);
|
|
686
|
+
return undefined;
|
|
687
|
+
}
|
|
688
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, intervalMs));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// resolveApiKey implements the key-intake precedence:
|
|
693
|
+
// existing .env key > --key flag > process env > interactive prompt > --wait.
|
|
694
|
+
// Only explicitly provided keys (--key/prompt/wait) may replace an existing
|
|
695
|
+
// .env value; the process env never overrides an already-configured project.
|
|
696
|
+
async function resolveApiKey(envPath, projectDir, options) {
|
|
697
|
+
const existingValue = readEnvKeyValue(envPath);
|
|
698
|
+
const flagKey = typeof options.key === 'string' && options.key.trim() ? options.key.trim() : undefined;
|
|
699
|
+
|
|
700
|
+
if (existingValue && !flagKey) {
|
|
701
|
+
return { key: existingValue, source: 'env-file' };
|
|
702
|
+
}
|
|
703
|
+
if (flagKey) {
|
|
704
|
+
return { key: flagKey, source: 'flag' };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const processKey = (options.env ?? process.env).PIEZAS_API_KEY;
|
|
708
|
+
if (processKey) {
|
|
709
|
+
return { key: processKey, source: 'process-env' };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (options.interactive && !options.yes) {
|
|
713
|
+
const promptFn = options.promptFn ?? promptViaTerminal;
|
|
714
|
+
const answer = String((await promptFn(KEY_PROMPT)) ?? '').trim();
|
|
715
|
+
if (answer) {
|
|
716
|
+
return { key: answer, source: 'prompt' };
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (options.wait) {
|
|
721
|
+
const timeoutSeconds = options.waitTimeout ?? DEFAULT_WAIT_TIMEOUT_SECONDS;
|
|
722
|
+
const intervalMs = options.waitIntervalMs ?? DEFAULT_WAIT_INTERVAL_MS;
|
|
723
|
+
const found = await waitForEnvKey(projectDir, timeoutSeconds, intervalMs);
|
|
724
|
+
if (found) {
|
|
725
|
+
return { key: found.value, source: 'wait', sourceFile: found.file };
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Nothing resolved: with a saved `piezas login` session (and a TTY or an
|
|
730
|
+
// explicit --from-login) create the app + mint a key via the platform.
|
|
731
|
+
const minted = await resolveKeyViaLogin(projectDir, options);
|
|
732
|
+
if (minted) {
|
|
733
|
+
return minted;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return { key: undefined, source: 'none' };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// --- Platform auth (piezas login / logout / whoami) ---------------------
|
|
740
|
+
|
|
741
|
+
// resolveApiBase: --api-base flag > PIEZAS_API_BASE env > fallback (the
|
|
742
|
+
// default recorded in piezas.manifest.json's apiBaseUrl).
|
|
743
|
+
function resolveApiBase(options, env, fallback = DEFAULT_API_BASE_URL) {
|
|
744
|
+
const flagBase = typeof options.apiBase === 'string' && options.apiBase.trim()
|
|
745
|
+
? options.apiBase.trim()
|
|
746
|
+
: undefined;
|
|
747
|
+
const envBase = typeof env.PIEZAS_API_BASE === 'string' && env.PIEZAS_API_BASE.trim()
|
|
748
|
+
? env.PIEZAS_API_BASE.trim()
|
|
749
|
+
: undefined;
|
|
750
|
+
return (flagBase || envBase || fallback).replace(/\/+$/, '');
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function adminBaseUrl(apiBase) {
|
|
754
|
+
return `${apiBase}${ADMIN_PATH_PREFIX}`;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// credentialsFilePath honors PIEZAS_HOME (used by tests) and defaults to
|
|
758
|
+
// ~/.piezas/credentials.json.
|
|
759
|
+
export function credentialsFilePath(env = process.env) {
|
|
760
|
+
const home = typeof env.PIEZAS_HOME === 'string' && env.PIEZAS_HOME.trim()
|
|
761
|
+
? env.PIEZAS_HOME.trim()
|
|
762
|
+
: join(homedir(), '.piezas');
|
|
763
|
+
return join(home, 'credentials.json');
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// readCredentials returns the stored CLI session or undefined. Corrupt or
|
|
767
|
+
// token-less files read as "logged out" — never throw.
|
|
768
|
+
function readCredentials(env = process.env) {
|
|
769
|
+
const filePath = credentialsFilePath(env);
|
|
770
|
+
if (!existsSync(filePath)) return undefined;
|
|
771
|
+
try {
|
|
772
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
773
|
+
if (!parsed || typeof parsed !== 'object' || typeof parsed.cliToken !== 'string' || !parsed.cliToken) {
|
|
774
|
+
return undefined;
|
|
775
|
+
}
|
|
776
|
+
return parsed;
|
|
777
|
+
} catch {
|
|
778
|
+
return undefined;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function writeCredentials(credentials, env = process.env) {
|
|
783
|
+
const filePath = credentialsFilePath(env);
|
|
784
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
785
|
+
writeFileSync(filePath, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
|
|
786
|
+
// writeFileSync's mode only applies on create — enforce on rewrite too.
|
|
787
|
+
chmodSync(filePath, 0o600);
|
|
788
|
+
return filePath;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// fetchJson: built-in fetch with a hard timeout and JSON parse guards.
|
|
792
|
+
// Non-2xx statuses are returned (not thrown); network errors/timeouts throw.
|
|
793
|
+
async function fetchJson(fetchFn, url, { method = 'GET', body, token, timeoutMs = HTTP_TIMEOUT_MS } = {}) {
|
|
794
|
+
const controller = new AbortController();
|
|
795
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
796
|
+
try {
|
|
797
|
+
const headers = { accept: 'application/json' };
|
|
798
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
799
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
800
|
+
const response = await fetchFn(url, {
|
|
801
|
+
method,
|
|
802
|
+
headers,
|
|
803
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
804
|
+
signal: controller.signal,
|
|
805
|
+
});
|
|
806
|
+
let data;
|
|
807
|
+
try {
|
|
808
|
+
data = await response.json();
|
|
809
|
+
} catch {
|
|
810
|
+
data = undefined;
|
|
811
|
+
}
|
|
812
|
+
return { status: response.status, data };
|
|
813
|
+
} finally {
|
|
814
|
+
clearTimeout(timer);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// openInBrowser best-effort opens a URL. Failures are swallowed — the URL is
|
|
819
|
+
// always printed, so the user can click/copy it manually.
|
|
820
|
+
function openInBrowser(url) {
|
|
821
|
+
try {
|
|
822
|
+
const [command, args] = process.platform === 'darwin'
|
|
823
|
+
? ['open', [url]]
|
|
824
|
+
: process.platform === 'win32'
|
|
825
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
826
|
+
: ['xdg-open', [url]];
|
|
827
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
828
|
+
child.on('error', () => {});
|
|
829
|
+
child.unref();
|
|
830
|
+
return true;
|
|
831
|
+
} catch {
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function printUserCode(userCode) {
|
|
837
|
+
const inner = ` ${userCode} `;
|
|
838
|
+
const line = '─'.repeat(inner.length);
|
|
839
|
+
log(` ╭${line}╮`);
|
|
840
|
+
log(` │${inner}│`);
|
|
841
|
+
log(` ╰${line}╯`);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const LOGIN_RETRY_HINT = 'Run `npx piezas login` to get a new code.';
|
|
845
|
+
|
|
846
|
+
export async function login(options = {}) {
|
|
847
|
+
const previousEmit = emit;
|
|
848
|
+
if (options.json) {
|
|
849
|
+
emit = (message) => process.stderr.write(`${message}\n`);
|
|
850
|
+
}
|
|
851
|
+
let outcome;
|
|
852
|
+
try {
|
|
853
|
+
outcome = await runLogin(options);
|
|
854
|
+
} finally {
|
|
855
|
+
emit = previousEmit;
|
|
856
|
+
}
|
|
857
|
+
if (options.json) {
|
|
858
|
+
console.log(JSON.stringify(outcome.summary, null, 2));
|
|
859
|
+
}
|
|
860
|
+
return { exitCode: outcome.exitCode, ...outcome.summary };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async function runLogin(options) {
|
|
864
|
+
const env = options.env ?? process.env;
|
|
865
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
866
|
+
const apiBase = resolveApiBase(options, env);
|
|
867
|
+
const admin = adminBaseUrl(apiBase);
|
|
868
|
+
|
|
869
|
+
log('\n 🧱 Piezas login\n');
|
|
870
|
+
|
|
871
|
+
let start;
|
|
872
|
+
try {
|
|
873
|
+
start = await fetchJson(fetchFn, `${admin}/v1/portal/device-codes`, {
|
|
874
|
+
method: 'POST',
|
|
875
|
+
timeoutMs: options.httpTimeoutMs,
|
|
876
|
+
});
|
|
877
|
+
} catch (error) {
|
|
878
|
+
log(` Could not reach ${apiBase} (${error?.message || 'network error'}). Check your connection and try again.`);
|
|
879
|
+
return { exitCode: 1, summary: { command: 'login', status: 'error', error: 'unreachable', apiBase } };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (start.status === 429) {
|
|
883
|
+
log(' Too many sign-in requests are pending right now - wait a few minutes and try again.');
|
|
884
|
+
return { exitCode: 1, summary: { command: 'login', status: 'error', error: 'rate-limited', apiBase } };
|
|
885
|
+
}
|
|
886
|
+
const device = start.data;
|
|
887
|
+
if (start.status !== 201 || !device || typeof device.deviceCode !== 'string' || typeof device.userCode !== 'string') {
|
|
888
|
+
log(` Sign-in could not be started (HTTP ${start.status}). Try again in a moment.`);
|
|
889
|
+
return { exitCode: 1, summary: { command: 'login', status: 'error', error: `http-${start.status}`, apiBase } };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const verificationUri = device.verificationUri || 'https://app.piezas.ai/device';
|
|
893
|
+
const confirmUrl = `${verificationUri}${verificationUri.includes('?') ? '&' : '?'}code=${encodeURIComponent(device.userCode)}`;
|
|
894
|
+
|
|
895
|
+
log(' Confirm this code in your browser to sign in:');
|
|
896
|
+
log('');
|
|
897
|
+
printUserCode(device.userCode);
|
|
898
|
+
log('');
|
|
899
|
+
log(` ${confirmUrl}`);
|
|
900
|
+
log('');
|
|
901
|
+
const opener = options.openBrowserFn ?? openInBrowser;
|
|
902
|
+
if (opener(confirmUrl)) {
|
|
903
|
+
log(' Opening your browser... (if nothing happens, use the link above)');
|
|
904
|
+
}
|
|
905
|
+
log(' Waiting for approval...');
|
|
906
|
+
|
|
907
|
+
const intervalMs = options.pollIntervalMs
|
|
908
|
+
?? (Number(device.interval) > 0 ? Number(device.interval) : 5) * 1000;
|
|
909
|
+
const lifetimeMs = (Number(device.expiresIn) > 0 ? Number(device.expiresIn) : 900) * 1000;
|
|
910
|
+
const deadline = Date.now() + lifetimeMs;
|
|
911
|
+
|
|
912
|
+
while (Date.now() < deadline) {
|
|
913
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, intervalMs));
|
|
914
|
+
let poll;
|
|
915
|
+
try {
|
|
916
|
+
poll = await fetchJson(fetchFn, `${admin}/v1/portal/device-codes/token`, {
|
|
917
|
+
method: 'POST',
|
|
918
|
+
body: { deviceCode: device.deviceCode },
|
|
919
|
+
timeoutMs: options.httpTimeoutMs,
|
|
920
|
+
});
|
|
921
|
+
} catch {
|
|
922
|
+
continue; // transient network trouble — keep polling until expiry
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (poll.status === 200 && poll.data?.status === 'ok' && typeof poll.data.cliToken === 'string') {
|
|
926
|
+
const credentials = {
|
|
927
|
+
cliToken: poll.data.cliToken,
|
|
928
|
+
tenantId: poll.data.tenantId,
|
|
929
|
+
tenantName: poll.data.tenantName,
|
|
930
|
+
email: poll.data.email,
|
|
931
|
+
apiBase,
|
|
932
|
+
};
|
|
933
|
+
const credentialsPath = writeCredentials(credentials, env);
|
|
934
|
+
log('');
|
|
935
|
+
log(` Logged in as ${credentials.email}${credentials.tenantName ? ` (${credentials.tenantName})` : ''}.`);
|
|
936
|
+
log(` Session saved to ${credentialsPath}`);
|
|
937
|
+
log(' Next: run `npx piezas init` in your project folder.\n');
|
|
938
|
+
return {
|
|
939
|
+
exitCode: 0,
|
|
940
|
+
summary: {
|
|
941
|
+
command: 'login',
|
|
942
|
+
status: 'ok',
|
|
943
|
+
tenantId: credentials.tenantId ?? null,
|
|
944
|
+
tenantName: credentials.tenantName ?? null,
|
|
945
|
+
email: credentials.email ?? null,
|
|
946
|
+
apiBase,
|
|
947
|
+
credentialsPath,
|
|
948
|
+
},
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
if (poll.status === 200) continue; // pending
|
|
952
|
+
if (poll.status === 404 || poll.status === 400) {
|
|
953
|
+
log(` This sign-in code is no longer valid (expired or already used). ${LOGIN_RETRY_HINT}`);
|
|
954
|
+
return { exitCode: 1, summary: { command: 'login', status: 'expired', apiBase } };
|
|
955
|
+
}
|
|
956
|
+
// Any other status (e.g. a transient 5xx) — keep polling until expiry.
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
log(` The sign-in code expired before it was approved. ${LOGIN_RETRY_HINT}`);
|
|
960
|
+
return { exitCode: 1, summary: { command: 'login', status: 'expired', apiBase } };
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// How long logout waits for the server-side session revocation before giving
|
|
964
|
+
// up and deleting the local credentials anyway.
|
|
965
|
+
const LOGOUT_REVOKE_TIMEOUT_MS = 2000;
|
|
966
|
+
|
|
967
|
+
export async function logout(options = {}) {
|
|
968
|
+
const env = options.env ?? process.env;
|
|
969
|
+
const credentialsPath = credentialsFilePath(env);
|
|
970
|
+
const credentials = readCredentials(env);
|
|
971
|
+
|
|
972
|
+
// Best-effort server-side revocation BEFORE deleting the local file: the
|
|
973
|
+
// 30-day cliToken is bound to a revocable server session, so logging out
|
|
974
|
+
// kills any copy of the token, not just this machine's file. Failures
|
|
975
|
+
// (offline, expired, 5xx, hung server) never block the local logout.
|
|
976
|
+
let revoked = false;
|
|
977
|
+
if (credentials?.cliToken) {
|
|
978
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
979
|
+
const apiBase = resolveApiBase(options, env, credentials.apiBase || DEFAULT_API_BASE_URL);
|
|
980
|
+
try {
|
|
981
|
+
const response = await fetchJson(fetchFn, `${adminBaseUrl(apiBase)}/v1/portal/cli-sessions/revoke`, {
|
|
982
|
+
method: 'POST',
|
|
983
|
+
token: credentials.cliToken,
|
|
984
|
+
timeoutMs: options.revokeTimeoutMs ?? LOGOUT_REVOKE_TIMEOUT_MS,
|
|
985
|
+
});
|
|
986
|
+
revoked = response.status === 200;
|
|
987
|
+
} catch {
|
|
988
|
+
// ignore — logout is local-first
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
let removed = false;
|
|
993
|
+
if (existsSync(credentialsPath)) {
|
|
994
|
+
unlinkSync(credentialsPath);
|
|
995
|
+
removed = true;
|
|
996
|
+
}
|
|
997
|
+
const summary = { command: 'logout', removed, revoked, credentialsPath };
|
|
998
|
+
if (options.json) {
|
|
999
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
1000
|
+
} else {
|
|
1001
|
+
console.log(removed ? ' Logged out of Piezas.' : ' Not logged in - nothing to remove.');
|
|
1002
|
+
}
|
|
1003
|
+
return { exitCode: 0, ...summary };
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
export function whoami(options = {}) {
|
|
1007
|
+
const env = options.env ?? process.env;
|
|
1008
|
+
const credentials = readCredentials(env);
|
|
1009
|
+
if (!credentials) {
|
|
1010
|
+
if (options.json) {
|
|
1011
|
+
console.log(JSON.stringify({ command: 'whoami', loggedIn: false }, null, 2));
|
|
1012
|
+
} else {
|
|
1013
|
+
console.log(' Not logged in. Run `npx piezas login` to sign in.');
|
|
1014
|
+
}
|
|
1015
|
+
return { exitCode: 1, command: 'whoami', loggedIn: false };
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Never include the cliToken here — whoami output is safe to paste anywhere.
|
|
1019
|
+
const summary = {
|
|
1020
|
+
command: 'whoami',
|
|
1021
|
+
loggedIn: true,
|
|
1022
|
+
tenantId: credentials.tenantId ?? null,
|
|
1023
|
+
tenantName: credentials.tenantName ?? null,
|
|
1024
|
+
email: credentials.email ?? null,
|
|
1025
|
+
apiBase: credentials.apiBase ?? DEFAULT_API_BASE_URL,
|
|
1026
|
+
};
|
|
1027
|
+
if (options.json) {
|
|
1028
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
1029
|
+
} else {
|
|
1030
|
+
console.log('\n 🧱 Piezas CLI session\n');
|
|
1031
|
+
console.log(` Tenant: ${summary.tenantName ?? 'unknown'}${summary.tenantId ? ` (${summary.tenantId})` : ''}`);
|
|
1032
|
+
console.log(` Email: ${summary.email ?? 'unknown'}`);
|
|
1033
|
+
console.log(` API: ${summary.apiBase}\n`);
|
|
1034
|
+
}
|
|
1035
|
+
return { exitCode: 0, ...summary };
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// slugifyAppName mirrors the Go control plane's slugify (and the dashboard
|
|
1039
|
+
// BFF): lowercase, [a-z0-9] runs joined by single hyphens, trimmed.
|
|
1040
|
+
function slugifyAppName(name) {
|
|
1041
|
+
return name
|
|
1042
|
+
.toLowerCase()
|
|
1043
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
1044
|
+
.replace(/^-+|-+$/g, '');
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// resolveKeyViaLogin: when no key was found by the normal intake chain but a
|
|
1048
|
+
// CLI session exists, create the app (or reuse it on a name conflict) and
|
|
1049
|
+
// mint a live key bound to it — the same composition the dashboard's
|
|
1050
|
+
// onboarding performs. Every failure degrades to "no key" with a friendly
|
|
1051
|
+
// message; init never crashes because of this step.
|
|
1052
|
+
async function resolveKeyViaLogin(projectDir, options) {
|
|
1053
|
+
if (!options.fromLogin && !(options.interactive && !options.yes)) return undefined;
|
|
1054
|
+
const env = options.env ?? process.env;
|
|
1055
|
+
const credentials = readCredentials(env);
|
|
1056
|
+
if (!credentials) return undefined;
|
|
1057
|
+
|
|
1058
|
+
const appName = (typeof options.app === 'string' && options.app.trim())
|
|
1059
|
+
? options.app.trim()
|
|
1060
|
+
: basename(projectDir);
|
|
1061
|
+
|
|
1062
|
+
if (!options.fromLogin) {
|
|
1063
|
+
const promptFn = options.promptFn ?? promptViaTerminal;
|
|
1064
|
+
const answer = String(
|
|
1065
|
+
(await promptFn(`You are signed in as ${credentials.email}. Create app "${appName}" and mint an API key now? [Y/n] `)) ?? '',
|
|
1066
|
+
).trim().toLowerCase();
|
|
1067
|
+
if (answer === 'n' || answer === 'no') return undefined;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
1071
|
+
const apiBase = resolveApiBase(options, env, credentials.apiBase || DEFAULT_API_BASE_URL);
|
|
1072
|
+
const admin = adminBaseUrl(apiBase);
|
|
1073
|
+
const tenantPath = `${admin}/v1/tenants/${credentials.tenantId}`;
|
|
1074
|
+
const httpOptions = { token: credentials.cliToken, timeoutMs: options.httpTimeoutMs };
|
|
1075
|
+
const sessionExpired = () => {
|
|
1076
|
+
log(' Your Piezas session has expired - run `npx piezas login`, then rerun init.');
|
|
1077
|
+
return undefined;
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
try {
|
|
1081
|
+
let app;
|
|
1082
|
+
const created = await fetchJson(fetchFn, `${tenantPath}/partitions`, {
|
|
1083
|
+
...httpOptions,
|
|
1084
|
+
method: 'POST',
|
|
1085
|
+
body: { name: appName },
|
|
1086
|
+
});
|
|
1087
|
+
if (created.status === 201 && created.data?.id) {
|
|
1088
|
+
app = created.data;
|
|
1089
|
+
log(` Created app "${app.name}"${credentials.tenantName ? ` in ${credentials.tenantName}` : ''}`);
|
|
1090
|
+
} else if (created.status === 409) {
|
|
1091
|
+
// The app already exists — find it by the same slug the platform
|
|
1092
|
+
// derived (or by exact name) and reuse it, exactly like the dashboard.
|
|
1093
|
+
const listed = await fetchJson(fetchFn, `${tenantPath}/partitions`, httpOptions);
|
|
1094
|
+
if (listed.status === 401) return sessionExpired();
|
|
1095
|
+
const slug = slugifyAppName(appName);
|
|
1096
|
+
const existing = Array.isArray(listed.data?.data)
|
|
1097
|
+
? listed.data.data.find((p) => p?.slug === slug || p?.name === appName)
|
|
1098
|
+
: undefined;
|
|
1099
|
+
if (!existing) {
|
|
1100
|
+
log(' An app with that name already exists but could not be found - continuing without a key.');
|
|
1101
|
+
return undefined;
|
|
1102
|
+
}
|
|
1103
|
+
app = existing;
|
|
1104
|
+
log(` Reusing existing app "${app.name}"`);
|
|
1105
|
+
} else if (created.status === 401) {
|
|
1106
|
+
return sessionExpired();
|
|
1107
|
+
} else {
|
|
1108
|
+
log(` Could not create the app (HTTP ${created.status}) - continuing without a key.`);
|
|
1109
|
+
return undefined;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const minted = await fetchJson(fetchFn, `${tenantPath}/api-keys`, {
|
|
1113
|
+
...httpOptions,
|
|
1114
|
+
method: 'POST',
|
|
1115
|
+
body: { name: appName, environment: 'live', partitionId: app.id },
|
|
1116
|
+
});
|
|
1117
|
+
if (minted.status === 401) return sessionExpired();
|
|
1118
|
+
if (minted.status !== 201 || typeof minted.data?.key !== 'string' || !minted.data.key) {
|
|
1119
|
+
log(` Could not mint an API key (HTTP ${minted.status}) - continuing without a key.`);
|
|
1120
|
+
return undefined;
|
|
1121
|
+
}
|
|
1122
|
+
log(` Minted a live API key for "${app.name}"`);
|
|
1123
|
+
return { key: minted.data.key, source: 'login' };
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
log(` Could not reach Piezas (${error?.message || 'network error'}) - continuing without a key.`);
|
|
1126
|
+
return undefined;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// --- End platform auth ---------------------------------------------------
|
|
1131
|
+
|
|
1132
|
+
function detectPackageManager(projectDir) {
|
|
1133
|
+
if (existsSync(resolve(projectDir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
1134
|
+
if (existsSync(resolve(projectDir, 'yarn.lock'))) return 'yarn';
|
|
1135
|
+
if (existsSync(resolve(projectDir, 'bun.lockb'))) return 'bun';
|
|
1136
|
+
return 'npm';
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// hasSdkDependency reports whether package.json already declares @piezas/sdk
|
|
1140
|
+
// (e.g. a `file:` tarball pin for testing a local build). Init must not clobber
|
|
1141
|
+
// such a pin by re-adding the published package from the registry.
|
|
1142
|
+
function hasSdkDependency(projectDir) {
|
|
1143
|
+
const pkgJsonPath = resolve(projectDir, 'package.json');
|
|
1144
|
+
if (!existsSync(pkgJsonPath)) return false;
|
|
1145
|
+
try {
|
|
1146
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
|
1147
|
+
return Boolean(
|
|
1148
|
+
pkg?.dependencies?.['@piezas/sdk'] || pkg?.devDependencies?.['@piezas/sdk'],
|
|
1149
|
+
);
|
|
1150
|
+
} catch {
|
|
1151
|
+
return false;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
export async function init(options = {}) {
|
|
1156
|
+
const previousEmit = emit;
|
|
1157
|
+
if (options.json) {
|
|
1158
|
+
emit = (message) => process.stderr.write(`${message}\n`);
|
|
1159
|
+
}
|
|
1160
|
+
let summary;
|
|
1161
|
+
try {
|
|
1162
|
+
summary = await runInit(options);
|
|
1163
|
+
} finally {
|
|
1164
|
+
emit = previousEmit;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (options.json) {
|
|
1168
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
1169
|
+
}
|
|
1170
|
+
return { exitCode: 0, ...summary };
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
async function runInit(options) {
|
|
1174
|
+
const currentDir = options.cwd || process.cwd();
|
|
1175
|
+
const targetArg = options.targetArg;
|
|
1176
|
+
const projectDir = targetArg && targetArg !== '.'
|
|
1177
|
+
? resolve(currentDir, targetArg)
|
|
1178
|
+
: currentDir;
|
|
1179
|
+
const deploymentMode = options.deploymentMode || DEFAULT_DEPLOYMENT_MODE;
|
|
1180
|
+
const productType = options.productType || 'custom-app';
|
|
1181
|
+
const mcpRequested = Boolean(options.mcp);
|
|
1182
|
+
const appName = typeof options.app === 'string' && options.app.trim() ? options.app.trim() : undefined;
|
|
1183
|
+
|
|
1184
|
+
log('\n 🧱 Piezas by Softmax Data\n');
|
|
1185
|
+
|
|
1186
|
+
if (!existsSync(projectDir)) {
|
|
1187
|
+
mkdirSync(projectDir, { recursive: true });
|
|
1188
|
+
log(` Created ${targetArg}`);
|
|
1189
|
+
} else if (projectDir !== currentDir) {
|
|
1190
|
+
log(` Using ${targetArg}`);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
ensurePackageJson(projectDir);
|
|
1194
|
+
|
|
1195
|
+
if (options.skipInstall) {
|
|
1196
|
+
log(' Skipped @piezas/sdk install\n');
|
|
1197
|
+
} else if (hasSdkDependency(projectDir)) {
|
|
1198
|
+
log(' Preserving existing @piezas/sdk dependency (pin left untouched)\n');
|
|
1199
|
+
} else {
|
|
1200
|
+
log(' Installing @piezas/sdk...');
|
|
1201
|
+
const pm = detectPackageManager(projectDir);
|
|
1202
|
+
try {
|
|
1203
|
+
execSync(`${pm} add @piezas/sdk`, { cwd: projectDir, stdio: 'pipe' });
|
|
1204
|
+
log(' Installed @piezas/sdk\n');
|
|
1205
|
+
} catch {
|
|
1206
|
+
log(' Failed to install - run manually: npm install @piezas/sdk\n');
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const manifestPath = resolve(projectDir, 'piezas.manifest.json');
|
|
1211
|
+
const manifest = upsertManifest(manifestPath, {
|
|
1212
|
+
deploymentMode,
|
|
1213
|
+
productType,
|
|
1214
|
+
recipes: options.recipes,
|
|
1215
|
+
mcp: mcpRequested,
|
|
1216
|
+
appName,
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
const piezasDir = resolve(projectDir, '.piezas');
|
|
1220
|
+
mkdirSync(piezasDir, { recursive: true });
|
|
1221
|
+
writeIfNew(
|
|
1222
|
+
resolve(piezasDir, 'spec-builder.md'),
|
|
1223
|
+
PIEZAS_SPEC_INSTRUCTIONS,
|
|
1224
|
+
'.piezas/spec-builder.md (Piezas spec mode)',
|
|
1225
|
+
{ startMarker: PIEZAS_SPEC_START, appendIfNoMarker: false },
|
|
1226
|
+
);
|
|
1227
|
+
|
|
1228
|
+
writeIfNew(resolve(projectDir, 'CLAUDE.md'), PIEZAS_INSTRUCTIONS, 'CLAUDE.md');
|
|
1229
|
+
|
|
1230
|
+
const commandsDir = resolve(projectDir, '.claude', 'commands');
|
|
1231
|
+
mkdirSync(commandsDir, { recursive: true });
|
|
1232
|
+
writeIfNew(
|
|
1233
|
+
resolve(commandsDir, 'piezas.md'),
|
|
1234
|
+
PIEZAS_INSTRUCTIONS,
|
|
1235
|
+
'.claude/commands/piezas.md (Claude Code slash command)',
|
|
1236
|
+
);
|
|
1237
|
+
writeIfNew(
|
|
1238
|
+
resolve(commandsDir, 'piezas-spec.md'),
|
|
1239
|
+
PIEZAS_SPEC_INSTRUCTIONS,
|
|
1240
|
+
'.claude/commands/piezas-spec.md (Claude Code spec command)',
|
|
1241
|
+
{ startMarker: PIEZAS_SPEC_START, appendIfNoMarker: false },
|
|
1242
|
+
);
|
|
1243
|
+
|
|
1244
|
+
const cursorRulesPath = resolve(projectDir, '.cursor', 'rules', 'piezas.mdc');
|
|
1245
|
+
mkdirSync(dirname(cursorRulesPath), { recursive: true });
|
|
1246
|
+
writeIfNew(
|
|
1247
|
+
cursorRulesPath,
|
|
1248
|
+
`---\ndescription: Use Piezas backend APIs and SDK\nalwaysApply: true\n---\n\n${PIEZAS_INSTRUCTIONS}`,
|
|
1249
|
+
'.cursor/rules/piezas.mdc',
|
|
1250
|
+
);
|
|
1251
|
+
writeIfNew(resolve(projectDir, '.cursorrules'), PIEZAS_INSTRUCTIONS, '.cursorrules');
|
|
1252
|
+
|
|
1253
|
+
writeIfNew(resolve(projectDir, 'AGENTS.md'), `# Project Instructions\n\n${PIEZAS_INSTRUCTIONS}`, 'AGENTS.md');
|
|
1254
|
+
writeIfNew(resolve(projectDir, '.windsurfrules'), PIEZAS_INSTRUCTIONS, '.windsurfrules');
|
|
1255
|
+
const mcpScaffold = scaffoldMcpRoute(projectDir, deploymentMode, mcpRequested);
|
|
1256
|
+
|
|
1257
|
+
const gitignoreUpdated = ensureGitignore(projectDir);
|
|
1258
|
+
|
|
1259
|
+
const envPath = resolve(projectDir, '.env');
|
|
1260
|
+
const resolved = await resolveApiKey(envPath, projectDir, options);
|
|
1261
|
+
let apiKeyStatus = 'missing';
|
|
1262
|
+
let keyFormatWarning = false;
|
|
1263
|
+
if (resolved.key) {
|
|
1264
|
+
if (!API_KEY_FORMAT.test(resolved.key)) {
|
|
1265
|
+
keyFormatWarning = true;
|
|
1266
|
+
log(' Warning: the key does not look like sk_live_... / sk_test_... - using it anyway.');
|
|
1267
|
+
}
|
|
1268
|
+
const action = writeEnvKey(envPath, resolved.key);
|
|
1269
|
+
apiKeyStatus = action === 'kept' ? 'configured' : 'written';
|
|
1270
|
+
if (action === 'created') log(' Created .env with PIEZAS_API_KEY');
|
|
1271
|
+
if (action === 'appended') log(' Added PIEZAS_API_KEY to .env');
|
|
1272
|
+
if (action === 'replaced') log(' Replaced PIEZAS_API_KEY in .env');
|
|
1273
|
+
// A --wait drop file other than .env itself is a transfer vehicle holding
|
|
1274
|
+
// the secret un-gitignored — remove it once the key lives in .env.
|
|
1275
|
+
if (resolved.sourceFile && resolved.sourceFile !== '.env') {
|
|
1276
|
+
try {
|
|
1277
|
+
unlinkSync(resolve(projectDir, resolved.sourceFile));
|
|
1278
|
+
log(` Removed ${resolved.sourceFile} (key moved to .env)`);
|
|
1279
|
+
} catch {
|
|
1280
|
+
log(` Warning: could not remove ${resolved.sourceFile} - delete it yourself; it contains your key and is not git-ignored.`);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
log('\n Done! Your project is set up for Piezas.\n');
|
|
1286
|
+
log(' Your AI coding agent now knows how to use Piezas:');
|
|
1287
|
+
log(' Claude Code -> /piezas or reads CLAUDE.md automatically');
|
|
1288
|
+
log(' Spec mode -> /piezas-spec in Claude Code, or ask Cursor/Codex to run Piezas spec mode');
|
|
1289
|
+
log(' Cursor -> reads .cursor/rules/piezas.mdc automatically');
|
|
1290
|
+
log(' Codex -> reads AGENTS.md automatically');
|
|
1291
|
+
log(' Windsurf -> reads .windsurfrules automatically');
|
|
1292
|
+
log('');
|
|
1293
|
+
if (apiKeyStatus === 'missing') {
|
|
1294
|
+
log(' apiKey: missing');
|
|
1295
|
+
log(` Put PIEZAS_API_KEY=<your key> in ${envPath} (dashboard -> API Keys -> "Download .env"), or rerun: npx piezas init --key <key>`);
|
|
1296
|
+
log(' No account key handy? Run `npx piezas login`, then `npx piezas init --from-login` to create an app + key from the terminal.');
|
|
1297
|
+
} else if (apiKeyStatus === 'configured') {
|
|
1298
|
+
log(` apiKey: configured (already in ${envPath})`);
|
|
1299
|
+
} else {
|
|
1300
|
+
log(` apiKey: written to ${envPath}`);
|
|
1301
|
+
}
|
|
1302
|
+
log('');
|
|
1303
|
+
log(' Next steps:');
|
|
1304
|
+
let step = 1;
|
|
1305
|
+
if (apiKeyStatus === 'missing') {
|
|
1306
|
+
log(` ${step++}. Get an API key at https://app.piezas.ai (dashboard -> API Keys)`);
|
|
1307
|
+
log(` ${step++}. Add PIEZAS_API_KEY=sk_live_xxx to .env (server-side only)`);
|
|
1308
|
+
}
|
|
1309
|
+
log(` ${step++}. Run /piezas-spec [idea] or ask for Piezas spec mode`);
|
|
1310
|
+
log(` ${step++}. After the spec is written, tell your AI agent to code from SPEC.md`);
|
|
1311
|
+
log(` ${step++}. Run npx piezas doctor after generated changes\n`);
|
|
1312
|
+
if (mcpScaffold.enabled) {
|
|
1313
|
+
log(` MCP route: ${mcpScaffold.routePath} (protect with app auth before public use)\n`);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
return {
|
|
1317
|
+
command: 'init',
|
|
1318
|
+
projectDir,
|
|
1319
|
+
apiKey: apiKeyStatus,
|
|
1320
|
+
apiKeySource: resolved.source,
|
|
1321
|
+
envPath,
|
|
1322
|
+
gitignoreUpdated,
|
|
1323
|
+
cursorRulesPath,
|
|
1324
|
+
appName: manifest?.appName ?? appName ?? null,
|
|
1325
|
+
apiBaseUrl: manifest?.apiBaseUrl ?? DEFAULT_API_BASE_URL,
|
|
1326
|
+
manifestPath,
|
|
1327
|
+
keyFormatWarning,
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function readJsonFile(filePath) {
|
|
1332
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function lineNumber(content, index) {
|
|
1336
|
+
if (index < 0) return undefined;
|
|
1337
|
+
return content.slice(0, index).split('\n').length;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function addFinding(findings, severity, code, message, file, line) {
|
|
1341
|
+
findings.push({ severity, code, message, file, line });
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function isTextFile(filePath) {
|
|
1345
|
+
const name = basename(filePath);
|
|
1346
|
+
return (
|
|
1347
|
+
TEXT_EXTENSIONS.has(extname(filePath)) ||
|
|
1348
|
+
name.startsWith('.env') ||
|
|
1349
|
+
name === '.gitignore' ||
|
|
1350
|
+
name === '.cursorrules' ||
|
|
1351
|
+
name === '.windsurfrules'
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function isCodeFile(filePath) {
|
|
1356
|
+
return CODE_EXTENSIONS.has(extname(filePath));
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function listFiles(projectDir) {
|
|
1360
|
+
const files = [];
|
|
1361
|
+
|
|
1362
|
+
function walk(dir) {
|
|
1363
|
+
for (const entry of readdirSync(dir)) {
|
|
1364
|
+
if (DEFAULT_EXCLUDED_DIRS.has(entry)) {
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const fullPath = join(dir, entry);
|
|
1369
|
+
const stat = statSync(fullPath);
|
|
1370
|
+
if (stat.isDirectory()) {
|
|
1371
|
+
walk(fullPath);
|
|
1372
|
+
} else if (stat.isFile() && isTextFile(fullPath)) {
|
|
1373
|
+
files.push(fullPath);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
walk(projectDir);
|
|
1379
|
+
return files;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function normalizeRel(projectDir, filePath) {
|
|
1383
|
+
return relative(projectDir, filePath).replaceAll('\\', '/');
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function loadManifest(projectDir, findings) {
|
|
1387
|
+
const manifestPath = resolve(projectDir, 'piezas.manifest.json');
|
|
1388
|
+
if (!existsSync(manifestPath)) {
|
|
1389
|
+
addFinding(
|
|
1390
|
+
findings,
|
|
1391
|
+
'warn',
|
|
1392
|
+
'PZ000',
|
|
1393
|
+
'Missing piezas.manifest.json. Run `npx piezas init` so agents and doctor share a source of truth.',
|
|
1394
|
+
);
|
|
1395
|
+
return {
|
|
1396
|
+
exists: false,
|
|
1397
|
+
path: manifestPath,
|
|
1398
|
+
manifest: buildDefaultManifest({ deploymentMode: 'unknown' }),
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
try {
|
|
1403
|
+
return {
|
|
1404
|
+
exists: true,
|
|
1405
|
+
path: manifestPath,
|
|
1406
|
+
manifest: readJsonFile(manifestPath),
|
|
1407
|
+
};
|
|
1408
|
+
} catch (error) {
|
|
1409
|
+
addFinding(
|
|
1410
|
+
findings,
|
|
1411
|
+
'error',
|
|
1412
|
+
'PZ000',
|
|
1413
|
+
`piezas.manifest.json is not valid JSON: ${error.message}`,
|
|
1414
|
+
'piezas.manifest.json',
|
|
1415
|
+
);
|
|
1416
|
+
return {
|
|
1417
|
+
exists: false,
|
|
1418
|
+
path: manifestPath,
|
|
1419
|
+
manifest: buildDefaultManifest({ deploymentMode: 'unknown' }),
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function packageJson(projectDir) {
|
|
1425
|
+
const packagePath = resolve(projectDir, 'package.json');
|
|
1426
|
+
if (!existsSync(packagePath)) return undefined;
|
|
1427
|
+
try {
|
|
1428
|
+
return readJsonFile(packagePath);
|
|
1429
|
+
} catch {
|
|
1430
|
+
return undefined;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function hasServerRuntimeArtifact(rel, content) {
|
|
1435
|
+
return (
|
|
1436
|
+
/^app\/api\/.+\/route\.[cm]?[tj]sx?$/.test(rel) ||
|
|
1437
|
+
/^pages\/api\//.test(rel) ||
|
|
1438
|
+
/(^|\/)middleware\.[cm]?[tj]s$/.test(rel) ||
|
|
1439
|
+
/['"]use server['"]/.test(content)
|
|
1440
|
+
);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function hasClientDirective(content) {
|
|
1444
|
+
return /^\s*['"]use client['"]/.test(content);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function scanSecretExposure(projectDir, files, findings) {
|
|
1448
|
+
for (const filePath of files) {
|
|
1449
|
+
const rel = normalizeRel(projectDir, filePath);
|
|
1450
|
+
if (!isCodeFile(filePath) && !basename(rel).startsWith('.env')) continue;
|
|
1451
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
1452
|
+
const nextPublicIndex = content.indexOf('NEXT_PUBLIC_PIEZAS_API_KEY');
|
|
1453
|
+
if (nextPublicIndex >= 0) {
|
|
1454
|
+
addFinding(
|
|
1455
|
+
findings,
|
|
1456
|
+
'error',
|
|
1457
|
+
'PZ001',
|
|
1458
|
+
'PIEZAS_API_KEY must never use a NEXT_PUBLIC_ prefix.',
|
|
1459
|
+
rel,
|
|
1460
|
+
lineNumber(content, nextPublicIndex),
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
if (hasClientDirective(content)) {
|
|
1465
|
+
for (const secret of ['PIEZAS_API_KEY', 'ADMIN_PASS', 'ADMIN_TOKEN', 'GOOGLE_CLIENT_SECRET', 'ZOOM_CLIENT_SECRET']) {
|
|
1466
|
+
const secretIndex = content.indexOf(secret);
|
|
1467
|
+
if (secretIndex >= 0) {
|
|
1468
|
+
addFinding(
|
|
1469
|
+
findings,
|
|
1470
|
+
'error',
|
|
1471
|
+
'PZ001',
|
|
1472
|
+
`${secret} is referenced from a client component. Keep secrets server-side only.`,
|
|
1473
|
+
rel,
|
|
1474
|
+
lineNumber(content, secretIndex),
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
if (basename(rel).startsWith('.env') && content.includes('PIEZAS_API_KEY=')) {
|
|
1481
|
+
const gitignorePath = resolve(projectDir, '.gitignore');
|
|
1482
|
+
const gitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
|
|
1483
|
+
if (!/(^|\n)\.env(\n|$)|(^|\n)\.env\*/.test(gitignore)) {
|
|
1484
|
+
addFinding(
|
|
1485
|
+
findings,
|
|
1486
|
+
'warn',
|
|
1487
|
+
'PZ001',
|
|
1488
|
+
'.env contains PIEZAS_API_KEY but .gitignore does not clearly ignore .env files.',
|
|
1489
|
+
rel,
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function scanDeploymentMode(projectDir, files, manifest, findings) {
|
|
1497
|
+
if (manifest.deploymentMode !== 'static') {
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
for (const filePath of files) {
|
|
1502
|
+
if (!isCodeFile(filePath)) continue;
|
|
1503
|
+
const rel = normalizeRel(projectDir, filePath);
|
|
1504
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
1505
|
+
if (hasServerRuntimeArtifact(rel, content)) {
|
|
1506
|
+
addFinding(
|
|
1507
|
+
findings,
|
|
1508
|
+
'error',
|
|
1509
|
+
'PZ002',
|
|
1510
|
+
'Static deployment mode cannot use API routes, middleware, or server actions.',
|
|
1511
|
+
rel,
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
if (content.includes('PIEZAS_API_KEY') || content.includes('ADMIN_PASS') || content.includes('ADMIN_TOKEN')) {
|
|
1515
|
+
addFinding(
|
|
1516
|
+
findings,
|
|
1517
|
+
'error',
|
|
1518
|
+
'PZ002',
|
|
1519
|
+
'Static deployment mode cannot use server-only secrets in deployed app code.',
|
|
1520
|
+
rel,
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function scanLocalDatabase(projectDir, files, findings) {
|
|
1527
|
+
const pkg = packageJson(projectDir);
|
|
1528
|
+
const deps = {
|
|
1529
|
+
...(pkg?.dependencies || {}),
|
|
1530
|
+
...(pkg?.devDependencies || {}),
|
|
1531
|
+
};
|
|
1532
|
+
for (const packageName of LOCAL_DB_PACKAGES) {
|
|
1533
|
+
if (deps[packageName]) {
|
|
1534
|
+
addFinding(
|
|
1535
|
+
findings,
|
|
1536
|
+
'warn',
|
|
1537
|
+
'PZ003',
|
|
1538
|
+
`Detected ${packageName}. Do not create local database tables for Piezas-backed business records.`,
|
|
1539
|
+
'package.json',
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
for (const filePath of files) {
|
|
1545
|
+
const rel = normalizeRel(projectDir, filePath);
|
|
1546
|
+
if (/(^|\/)(schema\.prisma|drizzle\.config\.)/.test(rel)) {
|
|
1547
|
+
addFinding(
|
|
1548
|
+
findings,
|
|
1549
|
+
'warn',
|
|
1550
|
+
'PZ003',
|
|
1551
|
+
'Detected local database schema/config. Confirm this is not duplicating Piezas-owned data.',
|
|
1552
|
+
rel,
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
function scanOAuthTokenStorage(projectDir, files, findings) {
|
|
1559
|
+
const tokenPattern = /\b(access_token|refresh_token|oauth_token|id_token)\b/i;
|
|
1560
|
+
for (const filePath of files) {
|
|
1561
|
+
if (!isCodeFile(filePath)) continue;
|
|
1562
|
+
const rel = normalizeRel(projectDir, filePath);
|
|
1563
|
+
if (rel.includes('.test.')) continue;
|
|
1564
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
1565
|
+
const match = tokenPattern.exec(content);
|
|
1566
|
+
if (match) {
|
|
1567
|
+
addFinding(
|
|
1568
|
+
findings,
|
|
1569
|
+
'warn',
|
|
1570
|
+
'PZ004',
|
|
1571
|
+
'OAuth tokens should live in Piezas Integrations, not app storage or app database records.',
|
|
1572
|
+
rel,
|
|
1573
|
+
lineNumber(content, match.index),
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function scanAdminTokenInUrl(projectDir, files, findings) {
|
|
1580
|
+
for (const filePath of files) {
|
|
1581
|
+
if (!isCodeFile(filePath)) continue;
|
|
1582
|
+
const rel = normalizeRel(projectDir, filePath);
|
|
1583
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
1584
|
+
const hasTokenQuery =
|
|
1585
|
+
/searchParams[\s\S]{0,300}\btoken\b/.test(content) ||
|
|
1586
|
+
/\?token=/.test(content) ||
|
|
1587
|
+
/method=["']get["'][\s\S]{0,300}name=["']token["']/.test(content);
|
|
1588
|
+
const hasAdminContext =
|
|
1589
|
+
/ADMIN_TOKEN|ADMIN_PASS|getOptionalAdminToken|admin token|Admin token/i.test(content) ||
|
|
1590
|
+
/(^|\/)admin(\/|$)/.test(rel);
|
|
1591
|
+
|
|
1592
|
+
if (hasTokenQuery && hasAdminContext) {
|
|
1593
|
+
addFinding(
|
|
1594
|
+
findings,
|
|
1595
|
+
'error',
|
|
1596
|
+
'PZ005',
|
|
1597
|
+
'Do not put admin tokens/passwords in query params. Use a POST login and HTTP-only session cookie or an auth role.',
|
|
1598
|
+
rel,
|
|
1599
|
+
lineNumber(content, content.indexOf('searchParams')),
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function scanHiddenTimeTrust(projectDir, files, findings) {
|
|
1606
|
+
for (const filePath of files) {
|
|
1607
|
+
if (!isCodeFile(filePath)) continue;
|
|
1608
|
+
const rel = normalizeRel(projectDir, filePath);
|
|
1609
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
1610
|
+
if (!/name=["']startTime["']/.test(content) || !/type=["']hidden["']/.test(content)) continue;
|
|
1611
|
+
if (!/formData\.get\(["']startTime["']\)/.test(content)) continue;
|
|
1612
|
+
if (!/create(Public)?Booking|book(ing)?/i.test(content)) continue;
|
|
1613
|
+
|
|
1614
|
+
const serverActionIndex = content.search(/['"]use server['"]/);
|
|
1615
|
+
const serverActionBody = serverActionIndex >= 0 ? content.slice(serverActionIndex) : content;
|
|
1616
|
+
if (!/listAvailableSlots|isSlotAvailable|validate.*Slot|revalidate.*Slot/i.test(serverActionBody)) {
|
|
1617
|
+
addFinding(
|
|
1618
|
+
findings,
|
|
1619
|
+
'error',
|
|
1620
|
+
'PZ006',
|
|
1621
|
+
'Public booking flow appears to trust a hidden startTime. Re-fetch availability and validate the slot server-side before booking.',
|
|
1622
|
+
rel,
|
|
1623
|
+
lineNumber(content, content.indexOf('startTime')),
|
|
1624
|
+
);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function dynamicParamsFromPath(rel) {
|
|
1630
|
+
const params = [];
|
|
1631
|
+
const pattern = /\[([A-Za-z0-9_]+)\]/g;
|
|
1632
|
+
let match = pattern.exec(rel);
|
|
1633
|
+
while (match) {
|
|
1634
|
+
params.push(match[1]);
|
|
1635
|
+
match = pattern.exec(rel);
|
|
1636
|
+
}
|
|
1637
|
+
return params;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function scanUnusedDynamicRoutes(projectDir, files, findings) {
|
|
1641
|
+
for (const filePath of files) {
|
|
1642
|
+
const rel = normalizeRel(projectDir, filePath);
|
|
1643
|
+
if (!/(^|\/)(page|route)\.[cm]?[tj]sx?$/.test(rel)) continue;
|
|
1644
|
+
const params = dynamicParamsFromPath(rel);
|
|
1645
|
+
if (params.length === 0) continue;
|
|
1646
|
+
|
|
1647
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
1648
|
+
for (const param of params) {
|
|
1649
|
+
const uses = content.match(new RegExp(`\\b${param}\\b`, 'g')) || [];
|
|
1650
|
+
if (uses.length <= 1) {
|
|
1651
|
+
addFinding(
|
|
1652
|
+
findings,
|
|
1653
|
+
'warn',
|
|
1654
|
+
'PZ007',
|
|
1655
|
+
`Dynamic route param [${param}] appears unused. Public routes should use all identity/scope params to avoid collisions.`,
|
|
1656
|
+
rel,
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
function scanIntegrationActions(projectDir, files, findings) {
|
|
1664
|
+
const actionPattern = /['"](?:google_calendar|zoom|hubspot|salesforce|slack|gmail)\.[A-Za-z0-9_.-]+['"]/g;
|
|
1665
|
+
for (const filePath of files) {
|
|
1666
|
+
if (!isCodeFile(filePath)) continue;
|
|
1667
|
+
const rel = normalizeRel(projectDir, filePath);
|
|
1668
|
+
if (rel.includes('.test.')) continue;
|
|
1669
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
1670
|
+
const actions = content.match(actionPattern);
|
|
1671
|
+
if (!actions) continue;
|
|
1672
|
+
|
|
1673
|
+
if (!/connector\.actions|connectors\.[A-Za-z0-9_]*actions|\.actions\b|capabilities/i.test(content)) {
|
|
1674
|
+
addFinding(
|
|
1675
|
+
findings,
|
|
1676
|
+
'warn',
|
|
1677
|
+
'PZ008',
|
|
1678
|
+
'Hardcoded integration action IDs found without checking connector action metadata first.',
|
|
1679
|
+
rel,
|
|
1680
|
+
lineNumber(content, content.indexOf(actions[0].slice(1, -1))),
|
|
1681
|
+
);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function projectHasServerRisk(projectDir, files) {
|
|
1687
|
+
return files.some((filePath) => {
|
|
1688
|
+
const rel = normalizeRel(projectDir, filePath);
|
|
1689
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
1690
|
+
return hasServerRuntimeArtifact(rel, content);
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function scanCoverageConfig(projectDir, files, findings) {
|
|
1695
|
+
const vitestConfig = files.find((filePath) => /(^|\/)vitest\.config\.[cm]?[tj]s$/.test(normalizeRel(projectDir, filePath)));
|
|
1696
|
+
if (!vitestConfig || !projectHasServerRisk(projectDir, files)) return;
|
|
1697
|
+
|
|
1698
|
+
const rel = normalizeRel(projectDir, vitestConfig);
|
|
1699
|
+
const content = readFileSync(vitestConfig, 'utf-8');
|
|
1700
|
+
if (/coverage\s*:\s*\{[\s\S]*include\s*:/.test(content) && !/['"`](src\/)?app\/|['"`]app\/|route\.[cm]?[tj]s/.test(content)) {
|
|
1701
|
+
addFinding(
|
|
1702
|
+
findings,
|
|
1703
|
+
'warn',
|
|
1704
|
+
'PZ009',
|
|
1705
|
+
'Coverage include list appears to exclude app/API/server-action code. Coverage may look high while risky routes remain untested.',
|
|
1706
|
+
rel,
|
|
1707
|
+
lineNumber(content, content.indexOf('include')),
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
function scanPackageScripts(projectDir, findings) {
|
|
1713
|
+
const pkg = packageJson(projectDir);
|
|
1714
|
+
if (!pkg) return;
|
|
1715
|
+
const nextVersion = pkg.dependencies?.next || pkg.devDependencies?.next;
|
|
1716
|
+
const major = Number(String(nextVersion || '').replace(/^[^0-9]*/, '').split('.')[0]);
|
|
1717
|
+
if (major >= 16 && pkg.scripts?.lint?.includes('next lint')) {
|
|
1718
|
+
addFinding(
|
|
1719
|
+
findings,
|
|
1720
|
+
'warn',
|
|
1721
|
+
'PZ010',
|
|
1722
|
+
'`next lint` is not reliable with this Next.js version. Use ESLint directly or update the lint script.',
|
|
1723
|
+
'package.json',
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
export function runDoctor(projectDir, options = {}) {
|
|
1729
|
+
const resolvedDir = resolve(projectDir);
|
|
1730
|
+
const findings = [];
|
|
1731
|
+
const manifestInfo = loadManifest(resolvedDir, findings);
|
|
1732
|
+
const files = existsSync(resolvedDir) ? listFiles(resolvedDir) : [];
|
|
1733
|
+
|
|
1734
|
+
scanSecretExposure(resolvedDir, files, findings);
|
|
1735
|
+
scanDeploymentMode(resolvedDir, files, manifestInfo.manifest, findings);
|
|
1736
|
+
scanLocalDatabase(resolvedDir, files, findings);
|
|
1737
|
+
scanOAuthTokenStorage(resolvedDir, files, findings);
|
|
1738
|
+
scanAdminTokenInUrl(resolvedDir, files, findings);
|
|
1739
|
+
scanHiddenTimeTrust(resolvedDir, files, findings);
|
|
1740
|
+
scanUnusedDynamicRoutes(resolvedDir, files, findings);
|
|
1741
|
+
scanIntegrationActions(resolvedDir, files, findings);
|
|
1742
|
+
scanCoverageConfig(resolvedDir, files, findings);
|
|
1743
|
+
scanPackageScripts(resolvedDir, findings);
|
|
1744
|
+
|
|
1745
|
+
const errors = findings.filter((finding) => finding.severity === 'error').length;
|
|
1746
|
+
const warnings = findings.filter((finding) => finding.severity === 'warn').length;
|
|
1747
|
+
|
|
1748
|
+
return {
|
|
1749
|
+
projectDir: resolvedDir,
|
|
1750
|
+
manifest: manifestInfo.manifest,
|
|
1751
|
+
manifestFound: manifestInfo.exists,
|
|
1752
|
+
findings,
|
|
1753
|
+
errors,
|
|
1754
|
+
warnings,
|
|
1755
|
+
passed: errors === 0 && (!options.strict || warnings === 0),
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
export function formatDoctorResult(result) {
|
|
1760
|
+
const lines = [
|
|
1761
|
+
'',
|
|
1762
|
+
' 🧱 Piezas Doctor',
|
|
1763
|
+
'',
|
|
1764
|
+
` Project: ${result.projectDir}`,
|
|
1765
|
+
` Manifest: ${result.manifestFound ? 'found' : 'missing'}`,
|
|
1766
|
+
` Deployment mode: ${result.manifest.deploymentMode || 'unknown'}`,
|
|
1767
|
+
'',
|
|
1768
|
+
];
|
|
1769
|
+
|
|
1770
|
+
if (result.findings.length === 0) {
|
|
1771
|
+
lines.push(' No issues found.');
|
|
1772
|
+
} else {
|
|
1773
|
+
for (const finding of result.findings) {
|
|
1774
|
+
const marker = finding.severity === 'error' ? 'ERROR' : 'WARN ';
|
|
1775
|
+
const location = finding.file
|
|
1776
|
+
? ` (${finding.file}${finding.line ? `:${finding.line}` : ''})`
|
|
1777
|
+
: '';
|
|
1778
|
+
lines.push(` ${marker} ${finding.code}${location}`);
|
|
1779
|
+
lines.push(` ${finding.message}`);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
lines.push('');
|
|
1784
|
+
lines.push(` Summary: ${result.errors} error(s), ${result.warnings} warning(s)`);
|
|
1785
|
+
lines.push('');
|
|
1786
|
+
return lines.join('\n');
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
function help() {
|
|
1790
|
+
console.log('\n 🧱 Piezas CLI\n');
|
|
1791
|
+
console.log(' Usage:');
|
|
1792
|
+
console.log(' npx piezas init [dir] Set up Piezas in a project');
|
|
1793
|
+
console.log(' npx piezas login Sign in via your browser (device code)');
|
|
1794
|
+
console.log(' npx piezas logout Remove the saved CLI session');
|
|
1795
|
+
console.log(' npx piezas whoami Show which account the CLI is signed in as');
|
|
1796
|
+
console.log(' npx piezas doctor [dir] Scan generated app guardrails\n');
|
|
1797
|
+
console.log(' Init options:');
|
|
1798
|
+
console.log(' --key <sk_live_...> API key to save into .env as PIEZAS_API_KEY');
|
|
1799
|
+
console.log(' --wait Watch the folder for a downloaded .env (.env / .env.download / env.txt)');
|
|
1800
|
+
console.log(' --wait-timeout <seconds> How long --wait polls before giving up (default 300)');
|
|
1801
|
+
console.log(' --app <name> App name recorded in piezas.manifest.json (and used for auto-created apps)');
|
|
1802
|
+
console.log(' --from-login When no key is found, create an app + key with your saved login');
|
|
1803
|
+
console.log(' --yes Non-interactive: never prompt (for agents/CI)');
|
|
1804
|
+
console.log(' --json Print a machine-readable JSON summary');
|
|
1805
|
+
console.log(' --mode next-bff|static|server-runtime|custom-backend');
|
|
1806
|
+
console.log(' --product-type custom-app|crm-intake|booking-site|...');
|
|
1807
|
+
console.log(' --recipe booking-site|crm-project-finance|client-services-os');
|
|
1808
|
+
console.log(' --recipe can be repeated or comma-separated');
|
|
1809
|
+
console.log(' --mcp Scaffold an SDK-backed MCP route for server modes');
|
|
1810
|
+
console.log(' --skip-install Do not install @piezas/sdk (alias: --no-deps)\n');
|
|
1811
|
+
console.log(' Login options:');
|
|
1812
|
+
console.log(' --api-base <url> Piezas API base URL (default https://api.piezas.ai; also PIEZAS_API_BASE)');
|
|
1813
|
+
console.log(' --json Print a machine-readable JSON summary\n');
|
|
1814
|
+
console.log(' logout / whoami options:');
|
|
1815
|
+
console.log(' --json Print machine-readable JSON\n');
|
|
1816
|
+
console.log(' Doctor options:');
|
|
1817
|
+
console.log(' --strict Treat warnings as failures');
|
|
1818
|
+
console.log(' --json Print machine-readable JSON\n');
|
|
1819
|
+
console.log(' Exit codes: 0 success, 1 failure, 2 usage error\n');
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
const INIT_FLAG_KEYS = new Set([
|
|
1823
|
+
'apiBase',
|
|
1824
|
+
'app',
|
|
1825
|
+
'fromLogin',
|
|
1826
|
+
'json',
|
|
1827
|
+
'key',
|
|
1828
|
+
'mcp',
|
|
1829
|
+
'mode',
|
|
1830
|
+
'noDeps',
|
|
1831
|
+
'productType',
|
|
1832
|
+
'recipe',
|
|
1833
|
+
'recipes',
|
|
1834
|
+
'skipDeps',
|
|
1835
|
+
'skipInstall',
|
|
1836
|
+
'wait',
|
|
1837
|
+
'waitTimeout',
|
|
1838
|
+
'yes',
|
|
1839
|
+
]);
|
|
1840
|
+
const DOCTOR_FLAG_KEYS = new Set(['json', 'strict']);
|
|
1841
|
+
const LOGIN_FLAG_KEYS = new Set(['apiBase', 'json']);
|
|
1842
|
+
const SESSION_FLAG_KEYS = new Set(['json']);
|
|
1843
|
+
|
|
1844
|
+
function kebabCase(flagKey) {
|
|
1845
|
+
return flagKey.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
function usageError(message) {
|
|
1849
|
+
console.error(` Error: ${message}`);
|
|
1850
|
+
console.error(' Run `npx piezas` for usage.');
|
|
1851
|
+
return 2;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function lastValue(value) {
|
|
1855
|
+
return Array.isArray(value) ? value[value.length - 1] : value;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function rejectUnknownFlags(flags, allowed, command) {
|
|
1859
|
+
for (const key of Object.keys(flags)) {
|
|
1860
|
+
if (!allowed.has(key)) {
|
|
1861
|
+
return usageError(`unknown flag --${kebabCase(key)} for ${command}`);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
return undefined;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
export async function main(argv = process.argv, currentDir = process.cwd()) {
|
|
1868
|
+
const command = argv[2];
|
|
1869
|
+
const { flags, positional } = parseCliArgs(argv.slice(3));
|
|
1870
|
+
|
|
1871
|
+
if (command === 'init') {
|
|
1872
|
+
const unknown = rejectUnknownFlags(flags, INIT_FLAG_KEYS, 'init');
|
|
1873
|
+
if (unknown !== undefined) return unknown;
|
|
1874
|
+
if (flags.wait && flags.key) {
|
|
1875
|
+
return usageError('--wait cannot be combined with --key');
|
|
1876
|
+
}
|
|
1877
|
+
if (flags.key !== undefined && typeof lastValue(flags.key) !== 'string') {
|
|
1878
|
+
return usageError('--key requires a value');
|
|
1879
|
+
}
|
|
1880
|
+
if (flags.app !== undefined && typeof lastValue(flags.app) !== 'string') {
|
|
1881
|
+
return usageError('--app requires a value');
|
|
1882
|
+
}
|
|
1883
|
+
let waitTimeout;
|
|
1884
|
+
if (flags.waitTimeout !== undefined) {
|
|
1885
|
+
waitTimeout = Number(lastValue(flags.waitTimeout));
|
|
1886
|
+
if (!Number.isFinite(waitTimeout) || waitTimeout <= 0) {
|
|
1887
|
+
return usageError('--wait-timeout requires a positive number of seconds');
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
const json = Boolean(flags.json);
|
|
1892
|
+
const yes = Boolean(flags.yes);
|
|
1893
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !yes && !json;
|
|
1894
|
+
|
|
1895
|
+
try {
|
|
1896
|
+
const result = await init({
|
|
1897
|
+
cwd: currentDir,
|
|
1898
|
+
targetArg: positional[0],
|
|
1899
|
+
deploymentMode: lastValue(flags.mode),
|
|
1900
|
+
productType: lastValue(flags.productType),
|
|
1901
|
+
recipes: flags.recipe ?? flags.recipes,
|
|
1902
|
+
mcp: Boolean(flags.mcp),
|
|
1903
|
+
skipInstall: Boolean(flags.skipInstall || flags.noDeps || flags.skipDeps),
|
|
1904
|
+
key: lastValue(flags.key),
|
|
1905
|
+
app: lastValue(flags.app),
|
|
1906
|
+
apiBase: lastValue(flags.apiBase),
|
|
1907
|
+
fromLogin: Boolean(flags.fromLogin),
|
|
1908
|
+
wait: Boolean(flags.wait),
|
|
1909
|
+
waitTimeout,
|
|
1910
|
+
yes,
|
|
1911
|
+
json,
|
|
1912
|
+
interactive,
|
|
1913
|
+
});
|
|
1914
|
+
return result.exitCode ?? 0;
|
|
1915
|
+
} catch (error) {
|
|
1916
|
+
console.error(` Error: ${error?.message || error}`);
|
|
1917
|
+
return 1;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
if (command === 'login') {
|
|
1922
|
+
const unknown = rejectUnknownFlags(flags, LOGIN_FLAG_KEYS, 'login');
|
|
1923
|
+
if (unknown !== undefined) return unknown;
|
|
1924
|
+
if (flags.apiBase !== undefined && typeof lastValue(flags.apiBase) !== 'string') {
|
|
1925
|
+
return usageError('--api-base requires a value');
|
|
1926
|
+
}
|
|
1927
|
+
try {
|
|
1928
|
+
const result = await login({
|
|
1929
|
+
apiBase: lastValue(flags.apiBase),
|
|
1930
|
+
json: Boolean(flags.json),
|
|
1931
|
+
});
|
|
1932
|
+
return result.exitCode;
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
console.error(` Error: ${error?.message || error}`);
|
|
1935
|
+
return 1;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
if (command === 'logout') {
|
|
1940
|
+
const unknown = rejectUnknownFlags(flags, SESSION_FLAG_KEYS, 'logout');
|
|
1941
|
+
if (unknown !== undefined) return unknown;
|
|
1942
|
+
try {
|
|
1943
|
+
return (await logout({ json: Boolean(flags.json) })).exitCode;
|
|
1944
|
+
} catch (error) {
|
|
1945
|
+
console.error(` Error: ${error?.message || error}`);
|
|
1946
|
+
return 1;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
if (command === 'whoami') {
|
|
1951
|
+
const unknown = rejectUnknownFlags(flags, SESSION_FLAG_KEYS, 'whoami');
|
|
1952
|
+
if (unknown !== undefined) return unknown;
|
|
1953
|
+
try {
|
|
1954
|
+
return whoami({ json: Boolean(flags.json) }).exitCode;
|
|
1955
|
+
} catch (error) {
|
|
1956
|
+
console.error(` Error: ${error?.message || error}`);
|
|
1957
|
+
return 1;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
if (command === 'doctor') {
|
|
1962
|
+
const unknown = rejectUnknownFlags(flags, DOCTOR_FLAG_KEYS, 'doctor');
|
|
1963
|
+
if (unknown !== undefined) return unknown;
|
|
1964
|
+
const projectDir = positional[0] ? resolve(currentDir, positional[0]) : currentDir;
|
|
1965
|
+
const result = runDoctor(projectDir, { strict: Boolean(flags.strict) });
|
|
1966
|
+
if (flags.json) {
|
|
1967
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1968
|
+
} else {
|
|
1969
|
+
console.log(formatDoctorResult(result));
|
|
1970
|
+
}
|
|
1971
|
+
return result.passed ? 0 : 1;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
if (command) {
|
|
1975
|
+
return usageError(`unknown command '${command}'`);
|
|
1976
|
+
}
|
|
1977
|
+
help();
|
|
1978
|
+
return 0;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
function isCliEntrypoint() {
|
|
1982
|
+
if (!process.argv[1]) return false;
|
|
1983
|
+
try {
|
|
1984
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
1985
|
+
} catch {
|
|
1986
|
+
return import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
if (isCliEntrypoint()) {
|
|
1991
|
+
main().then(
|
|
1992
|
+
(code) => {
|
|
1993
|
+
process.exitCode = code;
|
|
1994
|
+
},
|
|
1995
|
+
(error) => {
|
|
1996
|
+
console.error(` Error: ${error?.message || error}`);
|
|
1997
|
+
process.exitCode = 1;
|
|
1998
|
+
},
|
|
1999
|
+
);
|
|
2000
|
+
}
|