@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/commands/projects.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
4
|
import { promptSelect } from "../lib/hooks.ts";
|
|
5
5
|
import { error, info, item, output as outputSpinner, success, warn } from "../lib/output.ts";
|
|
6
|
+
import {
|
|
7
|
+
type ProjectListItem,
|
|
8
|
+
STATUS_ICONS,
|
|
9
|
+
buildTagColorMap,
|
|
10
|
+
colors,
|
|
11
|
+
filterByStatus,
|
|
12
|
+
filterByTag,
|
|
13
|
+
formatCloudSection,
|
|
14
|
+
formatErrorSection,
|
|
15
|
+
formatLocalSection,
|
|
16
|
+
formatTagsInline,
|
|
17
|
+
groupProjects,
|
|
18
|
+
sortByUpdated,
|
|
19
|
+
toListItems,
|
|
20
|
+
} from "../lib/project-list.ts";
|
|
6
21
|
import {
|
|
7
22
|
cleanupStaleProjects,
|
|
8
23
|
getProjectStatus,
|
|
@@ -51,133 +66,177 @@ export default async function projects(subcommand?: string, args: string[] = [])
|
|
|
51
66
|
}
|
|
52
67
|
}
|
|
53
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Extract a flag value from args (e.g., --status live -> "live")
|
|
71
|
+
*/
|
|
72
|
+
function extractFlagValue(args: string[], flag: string): string | null {
|
|
73
|
+
const idx = args.indexOf(flag);
|
|
74
|
+
if (idx !== -1 && idx + 1 < args.length) {
|
|
75
|
+
return args[idx + 1] ?? null;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Extract multiple flag values from args (e.g., --tag api --tag prod -> ["api", "prod"])
|
|
82
|
+
*/
|
|
83
|
+
function extractFlagValues(args: string[], flag: string): string[] {
|
|
84
|
+
const values: string[] = [];
|
|
85
|
+
for (let i = 0; i < args.length; i++) {
|
|
86
|
+
if (args[i] === flag && i + 1 < args.length) {
|
|
87
|
+
const value = args[i + 1];
|
|
88
|
+
if (value) values.push(value);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return values;
|
|
92
|
+
}
|
|
93
|
+
|
|
54
94
|
/**
|
|
55
95
|
* List all projects with status indicators
|
|
56
96
|
*/
|
|
57
|
-
async function listProjects(
|
|
97
|
+
async function listProjects(args: string[]): Promise<void> {
|
|
98
|
+
// Parse flags
|
|
99
|
+
const showAll = args.includes("--all") || args.includes("-a");
|
|
100
|
+
const statusFilter = extractFlagValue(args, "--status");
|
|
101
|
+
const tagFilters = extractFlagValues(args, "--tag");
|
|
102
|
+
const jsonOutput = args.includes("--json");
|
|
103
|
+
const localOnly = args.includes("--local");
|
|
104
|
+
const cloudOnly = args.includes("--cloud");
|
|
105
|
+
|
|
58
106
|
// Fetch all projects from registry and control plane
|
|
59
107
|
outputSpinner.start("Checking project status...");
|
|
60
108
|
const projects: ResolvedProject[] = await listAllProjects();
|
|
61
109
|
outputSpinner.stop();
|
|
62
110
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
info("Create a project with: jack new <name>");
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
111
|
+
// Convert to list items
|
|
112
|
+
let items = toListItems(projects);
|
|
68
113
|
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
114
|
+
// Apply filters
|
|
115
|
+
if (statusFilter) items = filterByStatus(items, statusFilter);
|
|
116
|
+
if (localOnly) items = items.filter((i) => i.isLocal);
|
|
117
|
+
if (cloudOnly) items = items.filter((i) => i.isCloudOnly);
|
|
118
|
+
if (tagFilters.length > 0) items = filterByTag(items, tagFilters);
|
|
72
119
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
120
|
+
// Handle empty state
|
|
121
|
+
if (items.length === 0) {
|
|
122
|
+
if (jsonOutput) {
|
|
123
|
+
console.log("[]");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
info("No projects found");
|
|
127
|
+
if (statusFilter || localOnly || cloudOnly || tagFilters.length > 0) {
|
|
128
|
+
info("Try removing filters to see all projects");
|
|
76
129
|
} else {
|
|
77
|
-
|
|
130
|
+
info("Create a project with: jack new <name>");
|
|
78
131
|
}
|
|
132
|
+
return;
|
|
79
133
|
}
|
|
80
134
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
135
|
+
// JSON output to stdout (pipeable)
|
|
136
|
+
if (jsonOutput) {
|
|
137
|
+
console.log(JSON.stringify(items, null, 2));
|
|
138
|
+
return;
|
|
85
139
|
}
|
|
86
140
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (!proj.localPath) continue;
|
|
92
|
-
const parent = dirname(proj.localPath);
|
|
93
|
-
if (!groups.has(parent)) {
|
|
94
|
-
// Replace home directory with ~ for display
|
|
95
|
-
const displayPath = parent.startsWith(home) ? `~${parent.slice(home.length)}` : parent;
|
|
96
|
-
groups.set(parent, { displayPath, projects: [] });
|
|
97
|
-
}
|
|
98
|
-
groups.get(parent)?.projects.push(proj);
|
|
141
|
+
// Flat table for --all mode
|
|
142
|
+
if (showAll) {
|
|
143
|
+
renderFlatTable(items);
|
|
144
|
+
return;
|
|
99
145
|
}
|
|
100
146
|
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
console.error("");
|
|
147
|
+
// Default: grouped view
|
|
148
|
+
renderGroupedView(items);
|
|
149
|
+
}
|
|
105
150
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Render the grouped view (default)
|
|
153
|
+
*/
|
|
154
|
+
function renderGroupedView(items: ProjectListItem[]): void {
|
|
155
|
+
const groups = groupProjects(items);
|
|
156
|
+
const total = items.length;
|
|
110
157
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (!proj) continue;
|
|
114
|
-
const isLast = i === sortedProjects.length - 1;
|
|
115
|
-
const prefix = isLast ? "└──" : "├──";
|
|
158
|
+
// Build consistent tag color map across all projects
|
|
159
|
+
const tagColorMap = buildTagColorMap(items);
|
|
116
160
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
161
|
+
console.error("");
|
|
162
|
+
info(`${total} projects`);
|
|
163
|
+
|
|
164
|
+
// Section 1: Errors (always show all)
|
|
165
|
+
if (groups.errors.length > 0) {
|
|
120
166
|
console.error("");
|
|
167
|
+
console.error(formatErrorSection(groups.errors, { tagColorMap }));
|
|
121
168
|
}
|
|
122
169
|
|
|
123
|
-
//
|
|
124
|
-
if (
|
|
125
|
-
console.error(` ${colors.dim}On jack cloud (no local files)${colors.reset}`);
|
|
126
|
-
const sortedCloudProjects = cloudOnlyProjects.sort((a, b) => a.name.localeCompare(b.name));
|
|
127
|
-
|
|
128
|
-
for (let i = 0; i < sortedCloudProjects.length; i++) {
|
|
129
|
-
const proj = sortedCloudProjects[i];
|
|
130
|
-
if (!proj) continue;
|
|
131
|
-
const isLast = i === sortedCloudProjects.length - 1;
|
|
132
|
-
const prefix = isLast ? "└──" : "├──";
|
|
133
|
-
|
|
134
|
-
const statusBadge = buildStatusBadge(proj);
|
|
135
|
-
console.error(` ${colors.dim}${prefix}${colors.reset} ${proj.name} ${statusBadge}`);
|
|
136
|
-
}
|
|
170
|
+
// Section 2: Local projects (grouped by parent dir)
|
|
171
|
+
if (groups.local.length > 0) {
|
|
137
172
|
console.error("");
|
|
173
|
+
console.error(
|
|
174
|
+
` ${colors.dim}${STATUS_ICONS["local-only"]} Local (${groups.local.length})${colors.reset}`,
|
|
175
|
+
);
|
|
176
|
+
console.error(formatLocalSection(groups.local, { tagColorMap }));
|
|
138
177
|
}
|
|
139
178
|
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
179
|
+
// Section 3: Cloud-only (show last N by updatedAt)
|
|
180
|
+
if (groups.cloudOnly.length > 0) {
|
|
181
|
+
const CLOUD_LIMIT = 5;
|
|
182
|
+
const sorted = sortByUpdated(groups.cloudOnly);
|
|
144
183
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
184
|
+
console.error("");
|
|
185
|
+
console.error(
|
|
186
|
+
formatCloudSection(sorted, { limit: CLOUD_LIMIT, total: groups.cloudOnly.length, tagColorMap }),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
149
189
|
|
|
150
|
-
|
|
151
|
-
|
|
190
|
+
// Footer hint
|
|
191
|
+
console.error("");
|
|
192
|
+
info("jack ls --all for full list, --status error to filter");
|
|
152
193
|
console.error("");
|
|
153
194
|
}
|
|
154
195
|
|
|
155
|
-
// Color codes
|
|
156
|
-
const colors = {
|
|
157
|
-
reset: "\x1b[0m",
|
|
158
|
-
dim: "\x1b[90m",
|
|
159
|
-
green: "\x1b[32m",
|
|
160
|
-
yellow: "\x1b[33m",
|
|
161
|
-
red: "\x1b[31m",
|
|
162
|
-
cyan: "\x1b[36m",
|
|
163
|
-
};
|
|
164
|
-
|
|
165
196
|
/**
|
|
166
|
-
*
|
|
197
|
+
* Render flat table (for --all mode)
|
|
167
198
|
*/
|
|
168
|
-
function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
199
|
+
function renderFlatTable(items: ProjectListItem[]): void {
|
|
200
|
+
// Sort: errors first, then by name
|
|
201
|
+
const sorted = [...items].sort((a, b) => {
|
|
202
|
+
if (a.status === "error" && b.status !== "error") return -1;
|
|
203
|
+
if (a.status !== "error" && b.status === "error") return 1;
|
|
204
|
+
return a.name.localeCompare(b.name);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Build consistent tag color map
|
|
208
|
+
const tagColorMap = buildTagColorMap(items);
|
|
209
|
+
|
|
210
|
+
console.error("");
|
|
211
|
+
info(`${items.length} projects`);
|
|
212
|
+
console.error("");
|
|
213
|
+
|
|
214
|
+
// Header
|
|
215
|
+
console.error(` ${colors.dim}${"NAME".padEnd(22)} ${"STATUS".padEnd(12)} URL${colors.reset}`);
|
|
216
|
+
|
|
217
|
+
// Rows
|
|
218
|
+
for (const item of sorted) {
|
|
219
|
+
const icon = STATUS_ICONS[item.status] || "?";
|
|
220
|
+
const statusColor =
|
|
221
|
+
item.status === "error" || item.status === "auth-expired"
|
|
222
|
+
? colors.red
|
|
223
|
+
: item.status === "live"
|
|
224
|
+
? colors.green
|
|
225
|
+
: item.status === "syncing"
|
|
226
|
+
? colors.yellow
|
|
227
|
+
: colors.dim;
|
|
228
|
+
|
|
229
|
+
const name = item.name.slice(0, 20).padEnd(22);
|
|
230
|
+
const tags = formatTagsInline(item.tags, tagColorMap);
|
|
231
|
+
const status = item.status.padEnd(12);
|
|
232
|
+
const url = item.url ? item.url.replace("https://", "") : "\u2014"; // em-dash
|
|
233
|
+
|
|
234
|
+
console.error(
|
|
235
|
+
` ${statusColor}${icon}${colors.reset} ${name}${tags ? ` ${tags}` : ""} ${statusColor}${status}${colors.reset} ${url}`,
|
|
236
|
+
);
|
|
180
237
|
}
|
|
238
|
+
|
|
239
|
+
console.error("");
|
|
181
240
|
}
|
|
182
241
|
|
|
183
242
|
/**
|
|
@@ -368,8 +427,8 @@ async function removeProjectEntry(args: string[]): Promise<void> {
|
|
|
368
427
|
|
|
369
428
|
// Show where we'll remove from
|
|
370
429
|
const locations: string[] = [];
|
|
371
|
-
if (project.sources.
|
|
372
|
-
locations.push("local
|
|
430
|
+
if (project.sources.filesystem) {
|
|
431
|
+
locations.push("local project");
|
|
373
432
|
}
|
|
374
433
|
if (project.sources.controlPlane) {
|
|
375
434
|
locations.push("jack cloud");
|
|
@@ -447,16 +506,15 @@ async function scanProjects(args: string[]): Promise<void> {
|
|
|
447
506
|
|
|
448
507
|
outputSpinner.start(`Scanning ${targetDir} for jack projects...`);
|
|
449
508
|
|
|
450
|
-
const {
|
|
451
|
-
"../lib/local-paths.ts"
|
|
452
|
-
);
|
|
509
|
+
const { scanAndRegisterProjects } = await import("../lib/paths-index.ts");
|
|
453
510
|
|
|
454
|
-
|
|
511
|
+
// scanAndRegisterProjects both discovers and registers projects
|
|
512
|
+
const discovered = await scanAndRegisterProjects(absoluteDir);
|
|
455
513
|
outputSpinner.stop();
|
|
456
514
|
|
|
457
515
|
if (discovered.length === 0) {
|
|
458
|
-
info("No jack projects found");
|
|
459
|
-
info("Projects must have a
|
|
516
|
+
info("No linked jack projects found");
|
|
517
|
+
info("Projects must have a .jack/project.json file");
|
|
460
518
|
return;
|
|
461
519
|
}
|
|
462
520
|
|
|
@@ -467,17 +525,16 @@ async function scanProjects(args: string[]): Promise<void> {
|
|
|
467
525
|
for (let i = 0; i < discovered.length; i++) {
|
|
468
526
|
const proj = discovered[i];
|
|
469
527
|
if (!proj) continue;
|
|
528
|
+
// Extract project name from path
|
|
529
|
+
const projectName = proj.path.split("/").pop() || proj.projectId;
|
|
470
530
|
const displayPath = proj.path.startsWith(home) ? `~${proj.path.slice(home.length)}` : proj.path;
|
|
471
531
|
const isLast = i === discovered.length - 1;
|
|
472
532
|
const prefix = isLast ? "└──" : "├──";
|
|
473
533
|
console.error(
|
|
474
|
-
` ${colors.dim}${prefix}${colors.reset} ${
|
|
534
|
+
` ${colors.dim}${prefix}${colors.reset} ${projectName} ${colors.dim}${displayPath}${colors.reset}`,
|
|
475
535
|
);
|
|
476
536
|
}
|
|
477
537
|
|
|
478
|
-
// Register all discovered projects
|
|
479
|
-
await registerDiscoveredProjects(discovered);
|
|
480
|
-
|
|
481
538
|
console.error("");
|
|
482
539
|
success(`Registered ${discovered.length} local path(s)`);
|
|
483
540
|
}
|
package/src/commands/secrets.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { $ } from "bun";
|
|
|
11
11
|
import { getControlApiUrl } from "../lib/control-plane.ts";
|
|
12
12
|
import { JackError, JackErrorCode } from "../lib/errors.ts";
|
|
13
13
|
import { error, info, output, success, warn } from "../lib/output.ts";
|
|
14
|
-
import { type
|
|
14
|
+
import { type LocalProjectLink, readProjectLink } from "../lib/project-link.ts";
|
|
15
15
|
import { getProjectNameFromDir } from "../lib/storage/index.ts";
|
|
16
16
|
|
|
17
17
|
interface SecretsOptions {
|
|
@@ -68,7 +68,7 @@ function showHelp(): void {
|
|
|
68
68
|
*/
|
|
69
69
|
async function resolveProjectContext(options: SecretsOptions): Promise<{
|
|
70
70
|
projectName: string;
|
|
71
|
-
|
|
71
|
+
link: LocalProjectLink | null;
|
|
72
72
|
isManaged: boolean;
|
|
73
73
|
projectId: string | null;
|
|
74
74
|
}> {
|
|
@@ -86,11 +86,12 @@ async function resolveProjectContext(options: SecretsOptions): Promise<{
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
const
|
|
89
|
+
// Read deploy mode from .jack/project.json
|
|
90
|
+
const link = await readProjectLink(process.cwd());
|
|
91
|
+
const isManaged = link?.deploy_mode === "managed";
|
|
92
|
+
const projectId = link?.project_id ?? null;
|
|
92
93
|
|
|
93
|
-
return { projectName,
|
|
94
|
+
return { projectName, link, isManaged, projectId };
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
/**
|
package/src/commands/services.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { fetchProjectResources } from "../lib/control-plane.ts";
|
|
|
3
3
|
import { formatSize } from "../lib/format.ts";
|
|
4
4
|
import { promptSelect } from "../lib/hooks.ts";
|
|
5
5
|
import { error, info, item, output as outputSpinner, success, warn } from "../lib/output.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { readProjectLink } from "../lib/project-link.ts";
|
|
7
7
|
import { parseWranglerResources } from "../lib/resources.ts";
|
|
8
8
|
import {
|
|
9
9
|
deleteDatabase,
|
|
@@ -43,12 +43,13 @@ async function ensureLocalProjectContext(projectName: string): Promise<void> {
|
|
|
43
43
|
* For BYO: parse from wrangler.jsonc
|
|
44
44
|
*/
|
|
45
45
|
async function resolveDatabaseInfo(projectName: string): Promise<ResolvedDatabaseInfo | null> {
|
|
46
|
-
|
|
46
|
+
// Read deploy mode from .jack/project.json
|
|
47
|
+
const link = await readProjectLink(process.cwd());
|
|
47
48
|
|
|
48
49
|
// For managed projects, fetch from control plane
|
|
49
|
-
if (
|
|
50
|
+
if (link?.deploy_mode === "managed") {
|
|
50
51
|
try {
|
|
51
|
-
const resources = await fetchProjectResources(
|
|
52
|
+
const resources = await fetchProjectResources(link.project_id);
|
|
52
53
|
const d1 = resources.find((r) => r.resource_type === "d1");
|
|
53
54
|
if (d1) {
|
|
54
55
|
return {
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jack tag - Manage project tags
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* jack tag add <tags...> Add tags to current project
|
|
6
|
+
* jack tag add <project> <tags...> Add tags to named project
|
|
7
|
+
* jack tag remove <tags...> Remove tags from current project
|
|
8
|
+
* jack tag remove <project> <tags...> Remove tags from named project
|
|
9
|
+
* jack tag list List all tags across projects
|
|
10
|
+
* jack tag list [project] List tags for a specific project
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { error, info, item, success } from "../lib/output.ts";
|
|
14
|
+
import { readProjectLink } from "../lib/project-link.ts";
|
|
15
|
+
import {
|
|
16
|
+
addTags,
|
|
17
|
+
findProjectPathByName,
|
|
18
|
+
getAllTagsWithCounts,
|
|
19
|
+
getProjectTags,
|
|
20
|
+
removeTags,
|
|
21
|
+
validateTags,
|
|
22
|
+
} from "../lib/tags.ts";
|
|
23
|
+
|
|
24
|
+
export default async function tag(subcommand?: string, args: string[] = []): Promise<void> {
|
|
25
|
+
if (!subcommand) {
|
|
26
|
+
showHelp();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
switch (subcommand) {
|
|
31
|
+
case "add":
|
|
32
|
+
return await addTagsCommand(args);
|
|
33
|
+
case "remove":
|
|
34
|
+
return await removeTagsCommand(args);
|
|
35
|
+
case "list":
|
|
36
|
+
return await listTagsCommand(args);
|
|
37
|
+
case "--help":
|
|
38
|
+
case "-h":
|
|
39
|
+
case "help":
|
|
40
|
+
showHelp();
|
|
41
|
+
return;
|
|
42
|
+
default:
|
|
43
|
+
error(`Unknown subcommand: ${subcommand}`);
|
|
44
|
+
info("Available: add, remove, list");
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Show help for the tag command
|
|
51
|
+
*/
|
|
52
|
+
function showHelp(): void {
|
|
53
|
+
console.error(`
|
|
54
|
+
jack tag - Manage project tags
|
|
55
|
+
|
|
56
|
+
Usage
|
|
57
|
+
$ jack tag add <tags...> Add tags to current project
|
|
58
|
+
$ jack tag add <project> <tags...> Add tags to named project
|
|
59
|
+
$ jack tag remove <tags...> Remove tags from current project
|
|
60
|
+
$ jack tag remove <project> <tags...> Remove tags from named project
|
|
61
|
+
$ jack tag list List all tags across projects
|
|
62
|
+
$ jack tag list [project] List tags for a specific project
|
|
63
|
+
|
|
64
|
+
Tag Format
|
|
65
|
+
Tags must be lowercase alphanumeric with optional colons and hyphens.
|
|
66
|
+
Examples: backend, api:v2, my-service, prod
|
|
67
|
+
|
|
68
|
+
Examples
|
|
69
|
+
$ jack tag add backend api Add tags in project directory
|
|
70
|
+
$ jack tag add my-app backend Add tag to my-app project
|
|
71
|
+
$ jack tag remove deprecated Remove tag from current project
|
|
72
|
+
$ jack tag list Show all tags with counts
|
|
73
|
+
$ jack tag list my-app Show tags for my-app
|
|
74
|
+
`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve project path from arguments
|
|
79
|
+
* Returns [projectPath, remainingArgs]
|
|
80
|
+
*
|
|
81
|
+
* Logic:
|
|
82
|
+
* 1. If in a linked project directory, use cwd and all args are tags
|
|
83
|
+
* 2. If not in project directory, first arg might be project name
|
|
84
|
+
*/
|
|
85
|
+
async function resolveProjectAndTags(args: string[]): Promise<[string | null, string[]]> {
|
|
86
|
+
const cwd = process.cwd();
|
|
87
|
+
|
|
88
|
+
// Check if we're in a linked project directory
|
|
89
|
+
const link = await readProjectLink(cwd);
|
|
90
|
+
|
|
91
|
+
if (link) {
|
|
92
|
+
// In a project directory - all args are tags
|
|
93
|
+
return [cwd, args];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Not in a project directory - first arg might be project name
|
|
97
|
+
if (args.length === 0) {
|
|
98
|
+
return [null, []];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const firstArg = args[0] as string; // Safe: we checked args.length > 0 above
|
|
102
|
+
const rest = args.slice(1);
|
|
103
|
+
|
|
104
|
+
// Try to find project by name
|
|
105
|
+
const projectPath = await findProjectPathByName(firstArg);
|
|
106
|
+
|
|
107
|
+
if (projectPath) {
|
|
108
|
+
// First arg was a project name
|
|
109
|
+
return [projectPath, rest];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// First arg wasn't a project name - we're not in a project directory
|
|
113
|
+
// and couldn't find a matching project
|
|
114
|
+
return [null, args];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Add tags to a project
|
|
119
|
+
*/
|
|
120
|
+
async function addTagsCommand(args: string[]): Promise<void> {
|
|
121
|
+
const [projectPath, tagArgs] = await resolveProjectAndTags(args);
|
|
122
|
+
|
|
123
|
+
if (!projectPath) {
|
|
124
|
+
error("Not in a project directory and no valid project name provided");
|
|
125
|
+
info("Run from a project directory or specify project name: jack tag add <project> <tags...>");
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (tagArgs.length === 0) {
|
|
130
|
+
error("No tags specified");
|
|
131
|
+
info("Usage: jack tag add <tags...>");
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Validate tags first
|
|
136
|
+
const validation = validateTags(tagArgs);
|
|
137
|
+
if (!validation.valid) {
|
|
138
|
+
error("Invalid tags:");
|
|
139
|
+
for (const { tag, reason } of validation.invalidTags) {
|
|
140
|
+
item(`"${tag}": ${reason}`);
|
|
141
|
+
}
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const result = await addTags(projectPath, tagArgs);
|
|
146
|
+
|
|
147
|
+
if (!result.success) {
|
|
148
|
+
error(result.error || "Failed to add tags");
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (result.added && result.added.length > 0) {
|
|
153
|
+
success(`Added tags: ${result.added.join(", ")}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (result.skipped && result.skipped.length > 0) {
|
|
157
|
+
info(`Already present: ${result.skipped.join(", ")}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (result.tags.length > 0) {
|
|
161
|
+
info(`Current tags: ${result.tags.join(", ")}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Remove tags from a project
|
|
167
|
+
*/
|
|
168
|
+
async function removeTagsCommand(args: string[]): Promise<void> {
|
|
169
|
+
const [projectPath, tagArgs] = await resolveProjectAndTags(args);
|
|
170
|
+
|
|
171
|
+
if (!projectPath) {
|
|
172
|
+
error("Not in a project directory and no valid project name provided");
|
|
173
|
+
info(
|
|
174
|
+
"Run from a project directory or specify project name: jack tag remove <project> <tags...>",
|
|
175
|
+
);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (tagArgs.length === 0) {
|
|
180
|
+
error("No tags specified");
|
|
181
|
+
info("Usage: jack tag remove <tags...>");
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const result = await removeTags(projectPath, tagArgs);
|
|
186
|
+
|
|
187
|
+
if (!result.success) {
|
|
188
|
+
error(result.error || "Failed to remove tags");
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (result.removed && result.removed.length > 0) {
|
|
193
|
+
success(`Removed tags: ${result.removed.join(", ")}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (result.skipped && result.skipped.length > 0) {
|
|
197
|
+
info(`Not found: ${result.skipped.join(", ")}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (result.tags.length > 0) {
|
|
201
|
+
info(`Remaining tags: ${result.tags.join(", ")}`);
|
|
202
|
+
} else {
|
|
203
|
+
info("No tags remaining");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* List tags for a project or all tags across projects
|
|
209
|
+
*/
|
|
210
|
+
async function listTagsCommand(args: string[]): Promise<void> {
|
|
211
|
+
const [projectArg] = args;
|
|
212
|
+
|
|
213
|
+
if (projectArg) {
|
|
214
|
+
// List tags for a specific project
|
|
215
|
+
await listProjectTags(projectArg);
|
|
216
|
+
} else {
|
|
217
|
+
// Check if we're in a project directory
|
|
218
|
+
const cwd = process.cwd();
|
|
219
|
+
const link = await readProjectLink(cwd);
|
|
220
|
+
|
|
221
|
+
if (link) {
|
|
222
|
+
// In a project directory - show tags for this project
|
|
223
|
+
await listProjectTagsForPath(cwd);
|
|
224
|
+
} else {
|
|
225
|
+
// Not in project directory - show all tags
|
|
226
|
+
await listAllTags();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* List tags for a specific project by name
|
|
233
|
+
*/
|
|
234
|
+
async function listProjectTags(projectName: string): Promise<void> {
|
|
235
|
+
const projectPath = await findProjectPathByName(projectName);
|
|
236
|
+
|
|
237
|
+
if (!projectPath) {
|
|
238
|
+
error(`Project not found: ${projectName}`);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
await listProjectTagsForPath(projectPath);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* List tags for a project at a specific path
|
|
247
|
+
*/
|
|
248
|
+
async function listProjectTagsForPath(projectPath: string): Promise<void> {
|
|
249
|
+
const tags = await getProjectTags(projectPath);
|
|
250
|
+
|
|
251
|
+
console.error("");
|
|
252
|
+
if (tags.length === 0) {
|
|
253
|
+
info("No tags for this project");
|
|
254
|
+
info("Add tags with: jack tag add <tags...>");
|
|
255
|
+
} else {
|
|
256
|
+
info(`Tags (${tags.length}):`);
|
|
257
|
+
for (const tag of tags) {
|
|
258
|
+
item(tag);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
console.error("");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* List all tags across all projects with counts
|
|
266
|
+
*/
|
|
267
|
+
async function listAllTags(): Promise<void> {
|
|
268
|
+
const tagCounts = await getAllTagsWithCounts();
|
|
269
|
+
|
|
270
|
+
console.error("");
|
|
271
|
+
if (tagCounts.length === 0) {
|
|
272
|
+
info("No tags found across any projects");
|
|
273
|
+
info("Add tags with: jack tag add <tags...>");
|
|
274
|
+
} else {
|
|
275
|
+
info(`All tags (${tagCounts.length}):`);
|
|
276
|
+
for (const { tag, count } of tagCounts) {
|
|
277
|
+
const projectLabel = count === 1 ? "project" : "projects";
|
|
278
|
+
item(`${tag} (${count} ${projectLabel})`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
console.error("");
|
|
282
|
+
}
|