@getjack/jack 0.1.3 → 0.1.5
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 +103 -0
- package/package.json +2 -6
- package/src/commands/agents.ts +9 -24
- package/src/commands/clone.ts +27 -0
- package/src/commands/down.ts +31 -57
- package/src/commands/feedback.ts +4 -5
- package/src/commands/link.ts +147 -0
- package/src/commands/logs.ts +8 -18
- package/src/commands/new.ts +7 -1
- package/src/commands/projects.ts +162 -105
- package/src/commands/secrets.ts +7 -6
- package/src/commands/services.ts +5 -4
- package/src/commands/tag.ts +282 -0
- package/src/commands/unlink.ts +30 -0
- package/src/index.ts +46 -1
- package/src/lib/auth/index.ts +2 -0
- package/src/lib/auth/store.ts +26 -2
- package/src/lib/binding-validator.ts +4 -13
- package/src/lib/build-helper.ts +93 -5
- package/src/lib/control-plane.ts +48 -0
- package/src/lib/deploy-mode.ts +1 -1
- package/src/lib/managed-deploy.ts +11 -1
- package/src/lib/managed-down.ts +7 -20
- package/src/lib/paths-index.test.ts +546 -0
- package/src/lib/paths-index.ts +310 -0
- package/src/lib/project-link.test.ts +459 -0
- package/src/lib/project-link.ts +279 -0
- package/src/lib/project-list.test.ts +581 -0
- package/src/lib/project-list.ts +445 -0
- package/src/lib/project-operations.ts +304 -183
- package/src/lib/project-resolver.ts +191 -211
- package/src/lib/tags.ts +389 -0
- package/src/lib/telemetry.ts +81 -168
- package/src/lib/zip-packager.ts +9 -0
- package/src/templates/index.ts +5 -3
- package/templates/api/.jack/template.json +4 -0
- package/templates/hello/.jack/template.json +4 -0
- package/templates/miniapp/.jack/template.json +4 -0
- package/templates/nextjs/.jack.json +28 -0
- package/templates/nextjs/app/globals.css +9 -0
- package/templates/nextjs/app/isr-test/page.tsx +22 -0
- package/templates/nextjs/app/layout.tsx +19 -0
- package/templates/nextjs/app/page.tsx +8 -0
- package/templates/nextjs/bun.lock +2232 -0
- package/templates/nextjs/cloudflare-env.d.ts +3 -0
- package/templates/nextjs/next-env.d.ts +6 -0
- package/templates/nextjs/next.config.ts +8 -0
- package/templates/nextjs/open-next.config.ts +6 -0
- package/templates/nextjs/package.json +24 -0
- package/templates/nextjs/public/_headers +2 -0
- package/templates/nextjs/tsconfig.json +44 -0
- package/templates/nextjs/wrangler.jsonc +17 -0
- package/src/lib/local-paths.test.ts +0 -902
- package/src/lib/local-paths.ts +0 -258
- package/src/lib/registry.ts +0 -181
package/src/lib/tags.ts
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tags Library
|
|
3
|
+
*
|
|
4
|
+
* Provides tag management for Jack projects.
|
|
5
|
+
* Tags are stored in .jack/project.json alongside other project link data.
|
|
6
|
+
*
|
|
7
|
+
* Design:
|
|
8
|
+
* - Tags are lowercase alphanumeric with colons and hyphens allowed
|
|
9
|
+
* - Single character tags are valid (e.g., "a", "1")
|
|
10
|
+
* - Multi-character tags must start and end with alphanumeric characters
|
|
11
|
+
* - Maximum 20 tags per project, 50 characters per tag
|
|
12
|
+
* - Tags are stored in sorted order for consistency
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getAllPaths } from "./paths-index.ts";
|
|
16
|
+
import { readProjectLink, updateProjectLink } from "./project-link.ts";
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Constants
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Regex for valid tag format:
|
|
24
|
+
* - Single alphanumeric character, OR
|
|
25
|
+
* - Multiple characters: starts with alphanumeric, ends with alphanumeric,
|
|
26
|
+
* middle can contain alphanumeric, colons, or hyphens
|
|
27
|
+
*/
|
|
28
|
+
export const TAG_REGEX = /^[a-z0-9][a-z0-9:-]*[a-z0-9]$|^[a-z0-9]$/;
|
|
29
|
+
|
|
30
|
+
/** Maximum length of a single tag */
|
|
31
|
+
export const MAX_TAG_LENGTH = 50;
|
|
32
|
+
|
|
33
|
+
/** Maximum number of tags per project */
|
|
34
|
+
export const MAX_TAGS_PER_PROJECT = 20;
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Types
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Result of tag validation
|
|
42
|
+
*/
|
|
43
|
+
export interface TagValidationResult {
|
|
44
|
+
valid: boolean;
|
|
45
|
+
errors: string[];
|
|
46
|
+
/** Tags that passed validation (normalized to lowercase) */
|
|
47
|
+
validTags: string[];
|
|
48
|
+
/** Tags that failed validation with reasons */
|
|
49
|
+
invalidTags: Array<{ tag: string; reason: string }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Result of a tag operation (add/remove)
|
|
54
|
+
*/
|
|
55
|
+
export interface TagOperationResult {
|
|
56
|
+
success: boolean;
|
|
57
|
+
/** Current tags after the operation */
|
|
58
|
+
tags: string[];
|
|
59
|
+
/** Tags that were added (for add operation) */
|
|
60
|
+
added?: string[];
|
|
61
|
+
/** Tags that were removed (for remove operation) */
|
|
62
|
+
removed?: string[];
|
|
63
|
+
/** Tags that were skipped (already existed for add, didn't exist for remove) */
|
|
64
|
+
skipped?: string[];
|
|
65
|
+
/** Error message if operation failed */
|
|
66
|
+
error?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Tag with usage count across projects
|
|
71
|
+
*/
|
|
72
|
+
export interface TagCount {
|
|
73
|
+
tag: string;
|
|
74
|
+
count: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// Validation
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if a single tag is valid
|
|
83
|
+
*/
|
|
84
|
+
export function isValidTag(tag: string): boolean {
|
|
85
|
+
if (!tag || typeof tag !== "string") {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const normalized = tag.toLowerCase().trim();
|
|
90
|
+
|
|
91
|
+
if (normalized.length === 0) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (normalized.length > MAX_TAG_LENGTH) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return TAG_REGEX.test(normalized);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validate an array of tags
|
|
104
|
+
* Returns validation result with valid tags normalized to lowercase
|
|
105
|
+
*/
|
|
106
|
+
export function validateTags(tags: string[]): TagValidationResult {
|
|
107
|
+
const errors: string[] = [];
|
|
108
|
+
const validTags: string[] = [];
|
|
109
|
+
const invalidTags: Array<{ tag: string; reason: string }> = [];
|
|
110
|
+
const seen = new Set<string>();
|
|
111
|
+
|
|
112
|
+
for (const tag of tags) {
|
|
113
|
+
const normalized = tag.toLowerCase().trim();
|
|
114
|
+
|
|
115
|
+
// Check for empty
|
|
116
|
+
if (!normalized) {
|
|
117
|
+
invalidTags.push({ tag, reason: "Tag cannot be empty" });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check for duplicates within the input
|
|
122
|
+
if (seen.has(normalized)) {
|
|
123
|
+
invalidTags.push({ tag, reason: "Duplicate tag" });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Check length
|
|
128
|
+
if (normalized.length > MAX_TAG_LENGTH) {
|
|
129
|
+
invalidTags.push({
|
|
130
|
+
tag,
|
|
131
|
+
reason: `Tag exceeds maximum length of ${MAX_TAG_LENGTH} characters`,
|
|
132
|
+
});
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Check format
|
|
137
|
+
if (!TAG_REGEX.test(normalized)) {
|
|
138
|
+
invalidTags.push({
|
|
139
|
+
tag,
|
|
140
|
+
reason:
|
|
141
|
+
"Tag must contain only lowercase letters, numbers, colons, and hyphens, and must start and end with a letter or number",
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
seen.add(normalized);
|
|
147
|
+
validTags.push(normalized);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (invalidTags.length > 0) {
|
|
151
|
+
errors.push(`Invalid tags: ${invalidTags.map((t) => `"${t.tag}" (${t.reason})`).join(", ")}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
valid: invalidTags.length === 0,
|
|
156
|
+
errors,
|
|
157
|
+
validTags,
|
|
158
|
+
invalidTags,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// Tag Operations
|
|
164
|
+
// ============================================================================
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get all tags for a project
|
|
168
|
+
* Returns empty array if project is not linked or has no tags
|
|
169
|
+
*/
|
|
170
|
+
export async function getProjectTags(projectPath: string): Promise<string[]> {
|
|
171
|
+
const link = await readProjectLink(projectPath);
|
|
172
|
+
|
|
173
|
+
if (!link) {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return link.tags ?? [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Add tags to a project
|
|
182
|
+
* Tags are deduplicated and sorted
|
|
183
|
+
*/
|
|
184
|
+
export async function addTags(projectPath: string, newTags: string[]): Promise<TagOperationResult> {
|
|
185
|
+
const link = await readProjectLink(projectPath);
|
|
186
|
+
|
|
187
|
+
if (!link) {
|
|
188
|
+
return {
|
|
189
|
+
success: false,
|
|
190
|
+
tags: [],
|
|
191
|
+
error: "Project is not linked. Run 'jack init' first.",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Validate new tags
|
|
196
|
+
const validation = validateTags(newTags);
|
|
197
|
+
if (!validation.valid) {
|
|
198
|
+
return {
|
|
199
|
+
success: false,
|
|
200
|
+
tags: link.tags ?? [],
|
|
201
|
+
error: validation.errors.join("; "),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const currentTags = new Set(link.tags ?? []);
|
|
206
|
+
const added: string[] = [];
|
|
207
|
+
const skipped: string[] = [];
|
|
208
|
+
|
|
209
|
+
for (const tag of validation.validTags) {
|
|
210
|
+
if (currentTags.has(tag)) {
|
|
211
|
+
skipped.push(tag);
|
|
212
|
+
} else {
|
|
213
|
+
currentTags.add(tag);
|
|
214
|
+
added.push(tag);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Check max tags limit
|
|
219
|
+
if (currentTags.size > MAX_TAGS_PER_PROJECT) {
|
|
220
|
+
return {
|
|
221
|
+
success: false,
|
|
222
|
+
tags: link.tags ?? [],
|
|
223
|
+
error: `Cannot add tags: would exceed maximum of ${MAX_TAGS_PER_PROJECT} tags per project`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Sort tags for consistent ordering
|
|
228
|
+
const sortedTags = Array.from(currentTags).sort();
|
|
229
|
+
|
|
230
|
+
// Update project link
|
|
231
|
+
await updateProjectLink(projectPath, { tags: sortedTags });
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
success: true,
|
|
235
|
+
tags: sortedTags,
|
|
236
|
+
added,
|
|
237
|
+
skipped,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Remove tags from a project
|
|
243
|
+
*/
|
|
244
|
+
export async function removeTags(
|
|
245
|
+
projectPath: string,
|
|
246
|
+
tagsToRemove: string[],
|
|
247
|
+
): Promise<TagOperationResult> {
|
|
248
|
+
const link = await readProjectLink(projectPath);
|
|
249
|
+
|
|
250
|
+
if (!link) {
|
|
251
|
+
return {
|
|
252
|
+
success: false,
|
|
253
|
+
tags: [],
|
|
254
|
+
error: "Project is not linked. Run 'jack init' first.",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const currentTags = new Set(link.tags ?? []);
|
|
259
|
+
const removed: string[] = [];
|
|
260
|
+
const skipped: string[] = [];
|
|
261
|
+
|
|
262
|
+
// Normalize tags to remove
|
|
263
|
+
const normalizedToRemove = tagsToRemove.map((t) => t.toLowerCase().trim());
|
|
264
|
+
|
|
265
|
+
for (const tag of normalizedToRemove) {
|
|
266
|
+
if (currentTags.has(tag)) {
|
|
267
|
+
currentTags.delete(tag);
|
|
268
|
+
removed.push(tag);
|
|
269
|
+
} else {
|
|
270
|
+
skipped.push(tag);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Sort tags for consistent ordering
|
|
275
|
+
const sortedTags = Array.from(currentTags).sort();
|
|
276
|
+
|
|
277
|
+
// Update project link (use empty array if no tags, or undefined to remove the field)
|
|
278
|
+
await updateProjectLink(projectPath, {
|
|
279
|
+
tags: sortedTags.length > 0 ? sortedTags : undefined,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
success: true,
|
|
284
|
+
tags: sortedTags,
|
|
285
|
+
removed,
|
|
286
|
+
skipped,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ============================================================================
|
|
291
|
+
// Tag Discovery
|
|
292
|
+
// ============================================================================
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get all unique tags across all projects with their usage counts
|
|
296
|
+
* Returns tags sorted by count (descending), then alphabetically
|
|
297
|
+
*/
|
|
298
|
+
export async function getAllTagsWithCounts(): Promise<TagCount[]> {
|
|
299
|
+
const allPaths = await getAllPaths();
|
|
300
|
+
const tagCounts = new Map<string, number>();
|
|
301
|
+
|
|
302
|
+
for (const paths of Object.values(allPaths)) {
|
|
303
|
+
// Use the first path for each project (they should all have the same tags)
|
|
304
|
+
const projectPath = paths[0];
|
|
305
|
+
if (!projectPath) continue;
|
|
306
|
+
|
|
307
|
+
const tags = await getProjectTags(projectPath);
|
|
308
|
+
for (const tag of tags) {
|
|
309
|
+
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Convert to array and sort
|
|
314
|
+
const result: TagCount[] = Array.from(tagCounts.entries()).map(([tag, count]) => ({
|
|
315
|
+
tag,
|
|
316
|
+
count,
|
|
317
|
+
}));
|
|
318
|
+
|
|
319
|
+
// Sort by count descending, then alphabetically
|
|
320
|
+
result.sort((a, b) => {
|
|
321
|
+
if (b.count !== a.count) {
|
|
322
|
+
return b.count - a.count;
|
|
323
|
+
}
|
|
324
|
+
return a.tag.localeCompare(b.tag);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Find a project path by project name
|
|
332
|
+
* Searches through all indexed paths and checks package.json or wrangler config for name
|
|
333
|
+
* Returns the first matching path, or null if not found
|
|
334
|
+
*/
|
|
335
|
+
export async function findProjectPathByName(name: string): Promise<string | null> {
|
|
336
|
+
const allPaths = await getAllPaths();
|
|
337
|
+
|
|
338
|
+
for (const paths of Object.values(allPaths)) {
|
|
339
|
+
for (const projectPath of paths) {
|
|
340
|
+
// Check package.json for name
|
|
341
|
+
try {
|
|
342
|
+
const packageJsonPath = `${projectPath}/package.json`;
|
|
343
|
+
const packageJson = await Bun.file(packageJsonPath).json();
|
|
344
|
+
if (packageJson.name === name) {
|
|
345
|
+
return projectPath;
|
|
346
|
+
}
|
|
347
|
+
} catch {
|
|
348
|
+
// No package.json or invalid JSON, continue
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Check wrangler.toml for name
|
|
352
|
+
try {
|
|
353
|
+
const wranglerPath = `${projectPath}/wrangler.toml`;
|
|
354
|
+
const wranglerContent = await Bun.file(wranglerPath).text();
|
|
355
|
+
// Simple regex to find name = "..." in TOML
|
|
356
|
+
const nameMatch = wranglerContent.match(/^name\s*=\s*["']([^"']+)["']/m);
|
|
357
|
+
if (nameMatch && nameMatch[1] === name) {
|
|
358
|
+
return projectPath;
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
// No wrangler.toml or can't read, continue
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Check wrangler.jsonc for name
|
|
365
|
+
try {
|
|
366
|
+
const wranglerJsonPath = `${projectPath}/wrangler.jsonc`;
|
|
367
|
+
const wranglerJson = await Bun.file(wranglerJsonPath).json();
|
|
368
|
+
if (wranglerJson.name === name) {
|
|
369
|
+
return projectPath;
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
// No wrangler.jsonc or invalid JSON, continue
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Check wrangler.json for name
|
|
376
|
+
try {
|
|
377
|
+
const wranglerJsonPath = `${projectPath}/wrangler.json`;
|
|
378
|
+
const wranglerJson = await Bun.file(wranglerJsonPath).json();
|
|
379
|
+
if (wranglerJson.name === name) {
|
|
380
|
+
return projectPath;
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
// No wrangler.json or invalid JSON, continue
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return null;
|
|
389
|
+
}
|