@getjack/jack 0.1.4 → 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.
Files changed (54) hide show
  1. package/package.json +2 -6
  2. package/src/commands/agents.ts +9 -24
  3. package/src/commands/clone.ts +27 -0
  4. package/src/commands/down.ts +31 -57
  5. package/src/commands/feedback.ts +4 -5
  6. package/src/commands/link.ts +147 -0
  7. package/src/commands/logs.ts +8 -18
  8. package/src/commands/new.ts +7 -1
  9. package/src/commands/projects.ts +162 -105
  10. package/src/commands/secrets.ts +7 -6
  11. package/src/commands/services.ts +5 -4
  12. package/src/commands/tag.ts +282 -0
  13. package/src/commands/unlink.ts +30 -0
  14. package/src/index.ts +46 -1
  15. package/src/lib/auth/index.ts +2 -0
  16. package/src/lib/auth/store.ts +26 -2
  17. package/src/lib/binding-validator.ts +4 -13
  18. package/src/lib/build-helper.ts +93 -5
  19. package/src/lib/control-plane.ts +48 -0
  20. package/src/lib/deploy-mode.ts +1 -1
  21. package/src/lib/managed-deploy.ts +11 -1
  22. package/src/lib/managed-down.ts +7 -20
  23. package/src/lib/paths-index.test.ts +546 -0
  24. package/src/lib/paths-index.ts +310 -0
  25. package/src/lib/project-link.test.ts +459 -0
  26. package/src/lib/project-link.ts +279 -0
  27. package/src/lib/project-list.test.ts +581 -0
  28. package/src/lib/project-list.ts +445 -0
  29. package/src/lib/project-operations.ts +304 -183
  30. package/src/lib/project-resolver.ts +191 -211
  31. package/src/lib/tags.ts +389 -0
  32. package/src/lib/telemetry.ts +81 -168
  33. package/src/lib/zip-packager.ts +9 -0
  34. package/src/templates/index.ts +5 -3
  35. package/templates/api/.jack/template.json +4 -0
  36. package/templates/hello/.jack/template.json +4 -0
  37. package/templates/miniapp/.jack/template.json +4 -0
  38. package/templates/nextjs/.jack.json +28 -0
  39. package/templates/nextjs/app/globals.css +9 -0
  40. package/templates/nextjs/app/isr-test/page.tsx +22 -0
  41. package/templates/nextjs/app/layout.tsx +19 -0
  42. package/templates/nextjs/app/page.tsx +8 -0
  43. package/templates/nextjs/bun.lock +2232 -0
  44. package/templates/nextjs/cloudflare-env.d.ts +3 -0
  45. package/templates/nextjs/next-env.d.ts +6 -0
  46. package/templates/nextjs/next.config.ts +8 -0
  47. package/templates/nextjs/open-next.config.ts +6 -0
  48. package/templates/nextjs/package.json +24 -0
  49. package/templates/nextjs/public/_headers +2 -0
  50. package/templates/nextjs/tsconfig.json +44 -0
  51. package/templates/nextjs/wrangler.jsonc +17 -0
  52. package/src/lib/local-paths.test.ts +0 -902
  53. package/src/lib/local-paths.ts +0 -258
  54. package/src/lib/registry.ts +0 -181
@@ -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
+ }