@indigoai-us/hq-cli 5.3.0 → 5.4.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.
Files changed (141) hide show
  1. package/dist/bin/hq-auth-refresh.d.ts +13 -0
  2. package/dist/bin/hq-auth-refresh.d.ts.map +1 -0
  3. package/dist/bin/hq-auth-refresh.js +27 -0
  4. package/dist/bin/hq-auth-refresh.js.map +1 -0
  5. package/dist/commands/auth.d.ts +17 -13
  6. package/dist/commands/auth.d.ts.map +1 -1
  7. package/dist/commands/auth.js +83 -238
  8. package/dist/commands/auth.js.map +1 -1
  9. package/dist/commands/cloud.d.ts +3 -12
  10. package/dist/commands/cloud.d.ts.map +1 -1
  11. package/dist/commands/cloud.js +55 -212
  12. package/dist/commands/cloud.js.map +1 -1
  13. package/dist/commands/login.d.ts +6 -0
  14. package/dist/commands/login.d.ts.map +1 -0
  15. package/dist/commands/login.js +25 -0
  16. package/dist/commands/login.js.map +1 -0
  17. package/dist/commands/logout.d.ts +6 -0
  18. package/dist/commands/logout.d.ts.map +1 -0
  19. package/dist/commands/logout.js +21 -0
  20. package/dist/commands/logout.js.map +1 -0
  21. package/dist/commands/onboard.d.ts +23 -0
  22. package/dist/commands/onboard.d.ts.map +1 -0
  23. package/dist/commands/onboard.js +142 -0
  24. package/dist/commands/onboard.js.map +1 -0
  25. package/dist/commands/pkg-install.d.ts +17 -0
  26. package/dist/commands/pkg-install.d.ts.map +1 -0
  27. package/dist/commands/pkg-install.js +144 -0
  28. package/dist/commands/pkg-install.js.map +1 -0
  29. package/dist/commands/pkg-list.d.ts +8 -0
  30. package/dist/commands/pkg-list.d.ts.map +1 -0
  31. package/dist/commands/pkg-list.js +70 -0
  32. package/dist/commands/pkg-list.js.map +1 -0
  33. package/dist/commands/pkg-remove.d.ts +10 -0
  34. package/dist/commands/pkg-remove.d.ts.map +1 -0
  35. package/dist/commands/pkg-remove.js +53 -0
  36. package/dist/commands/pkg-remove.js.map +1 -0
  37. package/dist/commands/pkg-update.d.ts +9 -0
  38. package/dist/commands/pkg-update.d.ts.map +1 -0
  39. package/dist/commands/pkg-update.js +124 -0
  40. package/dist/commands/pkg-update.js.map +1 -0
  41. package/dist/commands/team-sync.d.ts +14 -0
  42. package/dist/commands/team-sync.d.ts.map +1 -0
  43. package/dist/commands/team-sync.js +422 -0
  44. package/dist/commands/team-sync.js.map +1 -0
  45. package/dist/commands/whoami.d.ts +6 -0
  46. package/dist/commands/whoami.d.ts.map +1 -0
  47. package/dist/commands/whoami.js +29 -0
  48. package/dist/commands/whoami.js.map +1 -0
  49. package/dist/index.d.ts +1 -1
  50. package/dist/index.js +33 -10
  51. package/dist/index.js.map +1 -1
  52. package/dist/strategies/merge.js +2 -2
  53. package/dist/strategies/merge.js.map +1 -1
  54. package/dist/utils/auth.d.ts +27 -0
  55. package/dist/utils/auth.d.ts.map +1 -0
  56. package/dist/utils/auth.js +155 -0
  57. package/dist/utils/auth.js.map +1 -0
  58. package/dist/utils/cognito-session.d.ts +35 -0
  59. package/dist/utils/cognito-session.d.ts.map +1 -0
  60. package/dist/utils/cognito-session.js +72 -0
  61. package/dist/utils/cognito-session.js.map +1 -0
  62. package/dist/utils/git.js +1 -1
  63. package/dist/utils/git.js.map +1 -1
  64. package/dist/utils/hq-root.d.ts +10 -0
  65. package/dist/utils/hq-root.d.ts.map +1 -0
  66. package/dist/utils/hq-root.js +22 -0
  67. package/dist/utils/hq-root.js.map +1 -0
  68. package/dist/utils/integrity.d.ts +14 -0
  69. package/dist/utils/integrity.d.ts.map +1 -0
  70. package/dist/utils/integrity.js +40 -0
  71. package/dist/utils/integrity.js.map +1 -0
  72. package/dist/utils/manifest.d.ts.map +1 -1
  73. package/dist/utils/manifest.js +2 -5
  74. package/dist/utils/manifest.js.map +1 -1
  75. package/dist/utils/registry-client.d.ts +65 -0
  76. package/dist/utils/registry-client.d.ts.map +1 -0
  77. package/dist/utils/registry-client.js +102 -0
  78. package/dist/utils/registry-client.js.map +1 -0
  79. package/dist/utils/registry.d.ts +33 -0
  80. package/dist/utils/registry.d.ts.map +1 -0
  81. package/dist/utils/registry.js +58 -0
  82. package/dist/utils/registry.js.map +1 -0
  83. package/dist/utils/token-store.d.ts +28 -0
  84. package/dist/utils/token-store.d.ts.map +1 -0
  85. package/dist/utils/token-store.js +68 -0
  86. package/dist/utils/token-store.js.map +1 -0
  87. package/package.json +35 -45
  88. package/src/bin/hq-auth-refresh.ts +32 -0
  89. package/src/commands/add.ts +74 -0
  90. package/src/commands/auth.ts +153 -0
  91. package/src/commands/cloud.ts +125 -0
  92. package/src/commands/list.ts +66 -0
  93. package/src/commands/login.ts +29 -0
  94. package/src/commands/logout.ts +25 -0
  95. package/src/commands/pkg-install.ts +181 -0
  96. package/src/commands/pkg-list.ts +98 -0
  97. package/src/commands/pkg-remove.ts +71 -0
  98. package/src/commands/pkg-update.ts +189 -0
  99. package/src/commands/sync.ts +149 -0
  100. package/src/commands/team-sync.ts +629 -0
  101. package/src/commands/update.ts +71 -0
  102. package/src/commands/whoami.ts +38 -0
  103. package/src/index.ts +72 -0
  104. package/src/schemas/hq-package.schema.json +137 -0
  105. package/src/strategies/link.ts +62 -0
  106. package/src/strategies/merge.ts +142 -0
  107. package/src/types.ts +47 -0
  108. package/src/utils/auth.ts +193 -0
  109. package/src/utils/cognito-session.ts +94 -0
  110. package/src/utils/git.ts +74 -0
  111. package/src/utils/hq-root.ts +27 -0
  112. package/src/utils/integrity.ts +54 -0
  113. package/src/utils/manifest.ts +109 -0
  114. package/src/utils/registry-client.ts +204 -0
  115. package/src/utils/registry.ts +89 -0
  116. package/src/utils/token-store.ts +83 -0
  117. package/tsconfig.json +8 -0
  118. package/dist/commands/cloud-setup.d.ts +0 -19
  119. package/dist/commands/cloud-setup.d.ts.map +0 -1
  120. package/dist/commands/cloud-setup.js +0 -206
  121. package/dist/commands/cloud-setup.js.map +0 -1
  122. package/dist/commands/initial-upload.d.ts +0 -67
  123. package/dist/commands/initial-upload.d.ts.map +0 -1
  124. package/dist/commands/initial-upload.js +0 -205
  125. package/dist/commands/initial-upload.js.map +0 -1
  126. package/dist/sync-worker.d.ts +0 -11
  127. package/dist/sync-worker.d.ts.map +0 -1
  128. package/dist/sync-worker.js +0 -77
  129. package/dist/sync-worker.js.map +0 -1
  130. package/dist/utils/api-client.d.ts +0 -26
  131. package/dist/utils/api-client.d.ts.map +0 -1
  132. package/dist/utils/api-client.js +0 -87
  133. package/dist/utils/api-client.js.map +0 -1
  134. package/dist/utils/credentials.d.ts +0 -44
  135. package/dist/utils/credentials.d.ts.map +0 -1
  136. package/dist/utils/credentials.js +0 -101
  137. package/dist/utils/credentials.js.map +0 -1
  138. package/dist/utils/sync.d.ts +0 -125
  139. package/dist/utils/sync.d.ts.map +0 -1
  140. package/dist/utils/sync.js +0 -291
  141. package/dist/utils/sync.js.map +0 -1
@@ -0,0 +1,38 @@
1
+ /**
2
+ * hq whoami — displays current user or 'not logged in' (US-004)
3
+ */
4
+
5
+ import { Command } from 'commander';
6
+ import chalk from 'chalk';
7
+ import { loadToken, isTokenExpired } from '../utils/token-store.js';
8
+
9
+ export function registerWhoamiCommand(program: Command): void {
10
+ program
11
+ .command('whoami')
12
+ .description('Show the currently authenticated user')
13
+ .action(async () => {
14
+ try {
15
+ const token = await loadToken();
16
+
17
+ if (!token) {
18
+ console.log("Not logged in. Run 'hq login' to authenticate.");
19
+ return;
20
+ }
21
+
22
+ if (isTokenExpired(token)) {
23
+ console.log(
24
+ chalk.yellow(`Session expired for ${token.email}. Run 'hq login' to re-authenticate.`)
25
+ );
26
+ return;
27
+ }
28
+
29
+ console.log(`Logged in as ${token.email} (${token.user_id})`);
30
+ } catch (error) {
31
+ console.error(
32
+ chalk.red('Error:'),
33
+ error instanceof Error ? error.message : 'Unknown error'
34
+ );
35
+ process.exit(1);
36
+ }
37
+ });
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * HQ CLI - Module management, package management, and cloud sync for HQ
5
+ */
6
+
7
+ import { Command } from "commander";
8
+ import { registerAddCommand } from "./commands/add.js";
9
+ import { registerSyncCommand } from "./commands/sync.js";
10
+ import { registerListCommand } from "./commands/list.js";
11
+ import { registerUpdateCommand } from "./commands/update.js";
12
+ import { registerCloudCommands } from "./commands/cloud.js";
13
+ import { registerLoginCommand } from "./commands/login.js";
14
+ import { registerLogoutCommand } from "./commands/logout.js";
15
+ import { registerWhoamiCommand } from "./commands/whoami.js";
16
+ import { registerPackageInstallCommand } from "./commands/pkg-install.js";
17
+ import { registerPackageRemoveCommand } from "./commands/pkg-remove.js";
18
+ import { registerPackageUpdateCommand } from "./commands/pkg-update.js";
19
+ import { registerPackageListCommand } from "./commands/pkg-list.js";
20
+ import { registerTeamSyncCommand } from "./commands/team-sync.js";
21
+ import { registerAuthCommands } from "./commands/auth.js";
22
+
23
+ const program = new Command();
24
+
25
+ program
26
+ .name("hq")
27
+ .description("HQ management CLI — modules, packages, and cloud sync")
28
+ .version("5.4.0");
29
+
30
+ // Module management subcommand group
31
+ const modulesCmd = program
32
+ .command("modules")
33
+ .description("Module management commands");
34
+
35
+ registerAddCommand(modulesCmd);
36
+ registerSyncCommand(modulesCmd);
37
+ registerListCommand(modulesCmd);
38
+ registerUpdateCommand(modulesCmd);
39
+
40
+ // Package management subcommand group
41
+ const packagesCmd = program
42
+ .command("packages")
43
+ .description("Package management commands");
44
+
45
+ registerPackageInstallCommand(packagesCmd);
46
+ registerPackageRemoveCommand(packagesCmd);
47
+ registerPackageUpdateCommand(packagesCmd);
48
+ registerPackageListCommand(packagesCmd);
49
+
50
+ // Top-level shortcuts for package commands
51
+ // "hq install <slug>" = "hq packages install <slug>"
52
+ // "hq remove <slug>" = "hq packages remove <slug>"
53
+ registerPackageInstallCommand(program);
54
+ registerPackageRemoveCommand(program);
55
+
56
+ // Cloud sync subcommand group
57
+ const syncCmd = program
58
+ .command("sync")
59
+ .description("Cloud sync commands — sync HQ to S3 for mobile access");
60
+
61
+ registerCloudCommands(syncCmd);
62
+
63
+ // Team commands (top-level)
64
+ registerTeamSyncCommand(program);
65
+
66
+ // Auth commands (top-level)
67
+ registerLoginCommand(program);
68
+ registerLogoutCommand(program);
69
+ registerWhoamiCommand(program);
70
+ registerAuthCommands(program);
71
+
72
+ program.parse();
@@ -0,0 +1,137 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "hq-package.yaml",
4
+ "description": "Manifest schema for HQ packages — versioned bundles of workers, commands, skills, and knowledge installable via hq install.",
5
+ "type": "object",
6
+ "required": ["name", "type", "version", "description", "author", "exposes"],
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "name": {
10
+ "type": "string",
11
+ "description": "Package slug — lowercase alphanumeric with hyphens. Used as the registry identifier and install target directory name.",
12
+ "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$",
13
+ "minLength": 1
14
+ },
15
+ "type": {
16
+ "type": "string",
17
+ "description": "Package type. Controls install target routing for all exposes entries.",
18
+ "enum": ["worker-pack", "command-set", "skill-bundle", "knowledge-base", "company-template"]
19
+ },
20
+ "version": {
21
+ "type": "string",
22
+ "description": "Semver version string for this release.",
23
+ "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$"
24
+ },
25
+ "minHQVersion": {
26
+ "type": "string",
27
+ "description": "Minimum HQ version required to install this package. Installer aborts if installed HQ version is below this value.",
28
+ "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$"
29
+ },
30
+ "description": {
31
+ "type": "string",
32
+ "description": "Short human-readable description of what this package provides.",
33
+ "minLength": 1,
34
+ "maxLength": 500
35
+ },
36
+ "author": {
37
+ "type": "string",
38
+ "description": "Author name or organization that created and maintains this package.",
39
+ "minLength": 1
40
+ },
41
+ "repo": {
42
+ "type": "string",
43
+ "description": "Git repository URL for the package source. Used as offline fallback when the registry is unavailable.",
44
+ "pattern": "^(https?://|git@|ssh://).+"
45
+ },
46
+ "requires": {
47
+ "type": "object",
48
+ "description": "External dependencies this package needs at runtime.",
49
+ "additionalProperties": false,
50
+ "properties": {
51
+ "packages": {
52
+ "type": "array",
53
+ "description": "HQ package slugs this package depends on. Installed before this package.",
54
+ "items": {
55
+ "type": "string",
56
+ "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
57
+ },
58
+ "uniqueItems": true
59
+ },
60
+ "services": {
61
+ "type": "array",
62
+ "description": "Service slugs required at runtime. Installer warns if not configured in the target HQ installation.",
63
+ "items": {
64
+ "type": "string",
65
+ "minLength": 1
66
+ },
67
+ "uniqueItems": true
68
+ }
69
+ }
70
+ },
71
+ "exposes": {
72
+ "type": "object",
73
+ "description": "Declares what this package installs into the HQ workspace. At least one sub-array must be non-empty.",
74
+ "additionalProperties": false,
75
+ "properties": {
76
+ "workers": {
77
+ "type": "array",
78
+ "description": "Paths within the tarball to worker.yaml files to install.",
79
+ "items": {
80
+ "type": "string",
81
+ "minLength": 1
82
+ },
83
+ "uniqueItems": true
84
+ },
85
+ "commands": {
86
+ "type": "array",
87
+ "description": "Paths within the tarball to command .md files to install.",
88
+ "items": {
89
+ "type": "string",
90
+ "minLength": 1
91
+ },
92
+ "uniqueItems": true
93
+ },
94
+ "skills": {
95
+ "type": "array",
96
+ "description": "Paths within the tarball to skill .md files to install.",
97
+ "items": {
98
+ "type": "string",
99
+ "minLength": 1
100
+ },
101
+ "uniqueItems": true
102
+ },
103
+ "knowledge": {
104
+ "type": "array",
105
+ "description": "Paths within the tarball to knowledge directories or files to install.",
106
+ "items": {
107
+ "type": "string",
108
+ "minLength": 1
109
+ },
110
+ "uniqueItems": true
111
+ }
112
+ }
113
+ },
114
+ "hooks": {
115
+ "type": "object",
116
+ "description": "Shell scripts run at install lifecycle events. Each value is a path within the tarball to an executable shell script.",
117
+ "additionalProperties": false,
118
+ "properties": {
119
+ "on-install": {
120
+ "type": "string",
121
+ "description": "Path to shell script run after all files are installed.",
122
+ "minLength": 1
123
+ },
124
+ "on-update": {
125
+ "type": "string",
126
+ "description": "Path to shell script run after an existing installation is updated.",
127
+ "minLength": 1
128
+ },
129
+ "on-remove": {
130
+ "type": "string",
131
+ "description": "Path to shell script run before files are removed from the HQ workspace.",
132
+ "minLength": 1
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Link Sync Strategy (US-006)
3
+ * Symlinks module paths into HQ tree
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import type { ModuleDefinition, SyncResult } from '../types.js';
9
+
10
+ export async function linkSync(
11
+ module: ModuleDefinition,
12
+ moduleDir: string,
13
+ hqRoot: string
14
+ ): Promise<SyncResult> {
15
+ let filesChanged = 0;
16
+
17
+ for (const mapping of module.paths) {
18
+ const srcPath = path.join(moduleDir, mapping.src);
19
+ const destPath = path.join(hqRoot, mapping.dest);
20
+
21
+ // Validate source exists
22
+ if (!fs.existsSync(srcPath)) {
23
+ return {
24
+ module: module.name,
25
+ success: false,
26
+ action: 'skipped',
27
+ message: `Source path not found: ${mapping.src}`,
28
+ };
29
+ }
30
+
31
+ // Ensure dest parent directory exists
32
+ const destDir = path.dirname(destPath);
33
+ if (!fs.existsSync(destDir)) {
34
+ fs.mkdirSync(destDir, { recursive: true });
35
+ }
36
+
37
+ // Handle existing dest
38
+ if (fs.existsSync(destPath)) {
39
+ const stat = fs.lstatSync(destPath);
40
+ if (stat.isSymbolicLink()) {
41
+ // Remove existing symlink and recreate
42
+ fs.unlinkSync(destPath);
43
+ } else {
44
+ // Real file exists - warn and skip
45
+ console.warn(` Warning: Real file exists at ${mapping.dest}, skipping`);
46
+ continue;
47
+ }
48
+ }
49
+
50
+ // Create relative symlink for portability
51
+ const relativeSrc = path.relative(destDir, srcPath);
52
+ fs.symlinkSync(relativeSrc, destPath);
53
+ filesChanged++;
54
+ }
55
+
56
+ return {
57
+ module: module.name,
58
+ success: true,
59
+ action: 'synced',
60
+ filesChanged,
61
+ };
62
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Merge Sync Strategy (US-007)
3
+ * Copies files from module into HQ, tracks state for conflict detection
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import * as crypto from 'crypto';
9
+ import type { ModuleDefinition, SyncResult, SyncState } from '../types.js';
10
+ import { readState, writeState } from '../utils/manifest.js';
11
+
12
+ function hashFile(filePath: string): string {
13
+ const content = fs.readFileSync(filePath);
14
+ return crypto.createHash('sha256').update(content).digest('hex');
15
+ }
16
+
17
+ function copyRecursive(
18
+ srcDir: string,
19
+ destDir: string,
20
+ state: SyncState,
21
+ moduleName: string,
22
+ hqRoot: string,
23
+ filesChanged: { count: number }
24
+ ): void {
25
+ if (!fs.existsSync(destDir)) {
26
+ fs.mkdirSync(destDir, { recursive: true });
27
+ }
28
+
29
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
30
+ for (const entry of entries) {
31
+ const srcPath = path.join(srcDir, entry.name);
32
+ const destPath = path.join(destDir, entry.name);
33
+
34
+ if (entry.isDirectory()) {
35
+ copyRecursive(srcPath, destPath, state, moduleName, hqRoot, filesChanged);
36
+ } else {
37
+ const relativeDest = path.relative(hqRoot, destPath);
38
+ const newHash = hashFile(srcPath);
39
+
40
+ // Check if file exists and has been modified by user
41
+ if (fs.existsSync(destPath)) {
42
+ const existingHash = hashFile(destPath);
43
+ const lastSyncedHash = state.files[relativeDest]?.hash;
44
+
45
+ if (lastSyncedHash && existingHash !== lastSyncedHash && existingHash !== newHash) {
46
+ // User modified the file since last sync - skip (conflict)
47
+ console.warn(` Conflict: ${relativeDest} has local changes, skipping`);
48
+ continue;
49
+ }
50
+
51
+ if (existingHash === newHash) {
52
+ // File unchanged, skip
53
+ continue;
54
+ }
55
+ }
56
+
57
+ // Copy file
58
+ fs.copyFileSync(srcPath, destPath);
59
+ filesChanged.count++;
60
+
61
+ // Track in state
62
+ state.files[relativeDest] = {
63
+ hash: newHash,
64
+ syncedAt: new Date().toISOString(),
65
+ fromModule: moduleName,
66
+ };
67
+ }
68
+ }
69
+ }
70
+
71
+ export async function mergeSync(
72
+ module: ModuleDefinition,
73
+ moduleDir: string,
74
+ hqRoot: string
75
+ ): Promise<SyncResult> {
76
+ let state = readState(hqRoot);
77
+ if (!state) {
78
+ state = { version: '1', files: {} };
79
+ }
80
+
81
+ const filesChanged = { count: 0 };
82
+
83
+ for (const mapping of module.paths) {
84
+ const srcPath = path.join(moduleDir, mapping.src);
85
+ const destPath = path.join(hqRoot, mapping.dest);
86
+
87
+ if (!fs.existsSync(srcPath)) {
88
+ return {
89
+ module: module.name,
90
+ success: false,
91
+ action: 'skipped',
92
+ message: `Source path not found: ${mapping.src}`,
93
+ };
94
+ }
95
+
96
+ const srcStat = fs.statSync(srcPath);
97
+ if (srcStat.isDirectory()) {
98
+ copyRecursive(srcPath, destPath, state, module.name, hqRoot, filesChanged);
99
+ } else {
100
+ // Single file
101
+ const destDir = path.dirname(destPath);
102
+ if (!fs.existsSync(destDir)) {
103
+ fs.mkdirSync(destDir, { recursive: true });
104
+ }
105
+
106
+ const relativeDest = path.relative(hqRoot, destPath);
107
+ const newHash = hashFile(srcPath);
108
+
109
+ if (fs.existsSync(destPath)) {
110
+ const existingHash = hashFile(destPath);
111
+ const lastSyncedHash = state.files[relativeDest]?.hash;
112
+
113
+ if (lastSyncedHash && existingHash !== lastSyncedHash && existingHash !== newHash) {
114
+ console.warn(` Conflict: ${relativeDest} has local changes, skipping`);
115
+ continue;
116
+ }
117
+
118
+ if (existingHash === newHash) {
119
+ continue;
120
+ }
121
+ }
122
+
123
+ fs.copyFileSync(srcPath, destPath);
124
+ filesChanged.count++;
125
+
126
+ state.files[relativeDest] = {
127
+ hash: newHash,
128
+ syncedAt: new Date().toISOString(),
129
+ fromModule: module.name,
130
+ };
131
+ }
132
+ }
133
+
134
+ writeState(hqRoot, state);
135
+
136
+ return {
137
+ module: module.name,
138
+ success: true,
139
+ action: 'synced',
140
+ filesChanged: filesChanged.count,
141
+ };
142
+ }
package/src/types.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * HQ Module Manifest Types (US-001)
3
+ */
4
+
5
+ export type SyncStrategy = 'link' | 'merge' | 'copy';
6
+ export type AccessLevel = 'public' | 'team' | `role:${string}`;
7
+
8
+ export interface PathMapping {
9
+ src: string; // Path within module repo
10
+ dest: string; // Path in HQ tree (relative to HQ root)
11
+ }
12
+
13
+ export interface ModuleDefinition {
14
+ name: string;
15
+ repo: string; // Git URL (https or git@)
16
+ branch?: string; // Default: main
17
+ strategy: SyncStrategy; // link | merge | copy
18
+ paths: PathMapping[]; // What to sync and where
19
+ access?: AccessLevel; // For future RBAC
20
+ }
21
+
22
+ export interface ModulesManifest {
23
+ version: '1';
24
+ modules: ModuleDefinition[];
25
+ }
26
+
27
+ export interface ModuleLock {
28
+ version: '1';
29
+ locked: Record<string, string>; // module name -> commit SHA
30
+ }
31
+
32
+ export interface SyncState {
33
+ version: '1';
34
+ files: Record<string, {
35
+ hash: string; // SHA256 of file content at sync time
36
+ syncedAt: string; // ISO timestamp
37
+ fromModule: string; // Module that provided this file
38
+ }>;
39
+ }
40
+
41
+ export interface SyncResult {
42
+ module: string;
43
+ success: boolean;
44
+ action: 'cloned' | 'fetched' | 'synced' | 'skipped';
45
+ message?: string;
46
+ filesChanged?: number;
47
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Auth utilities — OAuth PKCE flow for Clerk (US-004)
3
+ *
4
+ * Token values are NEVER written to stdout or logs.
5
+ */
6
+
7
+ import * as crypto from 'crypto';
8
+ import * as http from 'http';
9
+ import { exec } from 'child_process';
10
+ import { saveToken, loadToken, isTokenExpired } from './token-store.js';
11
+ import { getRegistryUrl } from './registry-client.js';
12
+ import type { AuthToken } from './token-store.js';
13
+
14
+ /**
15
+ * Generate a cryptographically random PKCE code verifier (43-128 chars, URL-safe).
16
+ */
17
+ function generateCodeVerifier(): string {
18
+ return crypto.randomBytes(32).toString('base64url');
19
+ }
20
+
21
+ /**
22
+ * Derive the PKCE code challenge from the verifier (S256).
23
+ */
24
+ function generateCodeChallenge(verifier: string): string {
25
+ return crypto.createHash('sha256').update(verifier).digest('base64url');
26
+ }
27
+
28
+ /**
29
+ * Open a URL in the default browser (macOS / Linux).
30
+ */
31
+ function openBrowser(url: string): void {
32
+ const command =
33
+ process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
34
+ exec(command, (err) => {
35
+ if (err) {
36
+ // Non-fatal — user can manually open the URL
37
+ console.error(
38
+ `Could not open browser automatically. Please visit:\n ${url}`
39
+ );
40
+ }
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Start the OAuth PKCE auth flow:
46
+ * 1. Generate PKCE code_verifier + code_challenge
47
+ * 2. Start local HTTP server on a random port for the callback
48
+ * 3. Open browser to Clerk auth URL
49
+ * 4. Wait for callback with auth code
50
+ * 5. Exchange code for token via registry /auth/token endpoint
51
+ * 6. Return AuthToken
52
+ */
53
+ export async function startAuthFlow(
54
+ registryUrl: string
55
+ ): Promise<AuthToken> {
56
+ const codeVerifier = generateCodeVerifier();
57
+ const codeChallenge = generateCodeChallenge(codeVerifier);
58
+
59
+ return new Promise<AuthToken>((resolve, reject) => {
60
+ const server = http.createServer(async (req, res) => {
61
+ try {
62
+ const url = new URL(req.url ?? '/', `http://localhost`);
63
+
64
+ if (url.pathname !== '/callback') {
65
+ res.writeHead(404);
66
+ res.end('Not found');
67
+ return;
68
+ }
69
+
70
+ const code = url.searchParams.get('code');
71
+ const error = url.searchParams.get('error');
72
+
73
+ if (error) {
74
+ res.writeHead(200, { 'Content-Type': 'text/html' });
75
+ res.end(
76
+ '<html><body><h2>Authentication failed</h2><p>You can close this window.</p></body></html>'
77
+ );
78
+ server.close();
79
+ reject(new Error(`Auth failed: ${error}`));
80
+ return;
81
+ }
82
+
83
+ if (!code) {
84
+ res.writeHead(400, { 'Content-Type': 'text/html' });
85
+ res.end(
86
+ '<html><body><h2>Missing auth code</h2><p>You can close this window.</p></body></html>'
87
+ );
88
+ server.close();
89
+ reject(new Error('No authorization code received'));
90
+ return;
91
+ }
92
+
93
+ // Exchange code for token
94
+ const tokenResponse = await fetch(`${registryUrl}/auth/token`, {
95
+ method: 'POST',
96
+ headers: { 'Content-Type': 'application/json' },
97
+ body: JSON.stringify({
98
+ code,
99
+ code_verifier: codeVerifier,
100
+ redirect_uri: `http://localhost:${(server.address() as { port: number }).port}/callback`,
101
+ }),
102
+ });
103
+
104
+ if (!tokenResponse.ok) {
105
+ const body = await tokenResponse.text();
106
+ res.writeHead(200, { 'Content-Type': 'text/html' });
107
+ res.end(
108
+ '<html><body><h2>Token exchange failed</h2><p>You can close this window.</p></body></html>'
109
+ );
110
+ server.close();
111
+ reject(
112
+ new Error(`Token exchange failed (${tokenResponse.status}): ${body}`)
113
+ );
114
+ return;
115
+ }
116
+
117
+ const tokenData = (await tokenResponse.json()) as AuthToken;
118
+
119
+ res.writeHead(200, { 'Content-Type': 'text/html' });
120
+ res.end(
121
+ '<html><body><h2>Authenticated!</h2><p>You can close this window and return to the terminal.</p></body></html>'
122
+ );
123
+ server.close();
124
+ resolve(tokenData);
125
+ } catch (err) {
126
+ server.close();
127
+ reject(err);
128
+ }
129
+ });
130
+
131
+ // Listen on random port
132
+ server.listen(0, '127.0.0.1', () => {
133
+ const addr = server.address() as { port: number };
134
+ const redirectUri = `http://localhost:${addr.port}/callback`;
135
+ const authUrl = `${registryUrl}/auth/login?redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${codeChallenge}&code_challenge_method=S256`;
136
+
137
+ console.log('Opening browser for authentication...');
138
+ openBrowser(authUrl);
139
+ console.log(`\nIf the browser doesn't open, visit:\n ${authUrl}\n`);
140
+ });
141
+
142
+ // Timeout after 2 minutes
143
+ setTimeout(() => {
144
+ server.close();
145
+ reject(new Error('Authentication timed out after 2 minutes'));
146
+ }, 120_000);
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Get a valid auth token:
152
+ * 1. Load from store
153
+ * 2. If expired or within 5 min buffer: try refresh
154
+ * 3. If refresh fails: throw with message to re-login
155
+ * 4. Return valid token
156
+ *
157
+ * Token values are NEVER written to stdout.
158
+ */
159
+ export async function getAuthToken(): Promise<AuthToken> {
160
+ const token = await loadToken();
161
+
162
+ if (!token) {
163
+ throw new Error("Not logged in. Run 'hq login' to authenticate.");
164
+ }
165
+
166
+ if (!isTokenExpired(token)) {
167
+ return token;
168
+ }
169
+
170
+ // Attempt refresh
171
+ try {
172
+ const registryUrl = getRegistryUrl();
173
+ const refreshResponse = await fetch(`${registryUrl}/auth/refresh`, {
174
+ method: 'POST',
175
+ headers: { 'Content-Type': 'application/json' },
176
+ body: JSON.stringify({
177
+ clerk_session_token: token.clerk_session_token,
178
+ }),
179
+ });
180
+
181
+ if (refreshResponse.ok) {
182
+ const refreshedToken = (await refreshResponse.json()) as AuthToken;
183
+ await saveToken(refreshedToken);
184
+ return refreshedToken;
185
+ }
186
+ } catch {
187
+ // Refresh failed — fall through to error
188
+ }
189
+
190
+ throw new Error(
191
+ "Session expired. Run 'hq login' to re-authenticate."
192
+ );
193
+ }