@indigoai-us/hq-cli 5.3.1 → 5.5.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/dist/bin/hq-auth-refresh.d.ts +13 -0
- package/dist/bin/hq-auth-refresh.d.ts.map +1 -0
- package/dist/bin/hq-auth-refresh.js +27 -0
- package/dist/bin/hq-auth-refresh.js.map +1 -0
- package/dist/commands/auth.d.ts +17 -13
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +83 -238
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/cloud.d.ts +3 -12
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +55 -212
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/login.d.ts +6 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +25 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +6 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +21 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/pkg-install.d.ts +17 -0
- package/dist/commands/pkg-install.d.ts.map +1 -0
- package/dist/commands/pkg-install.js +144 -0
- package/dist/commands/pkg-install.js.map +1 -0
- package/dist/commands/pkg-list.d.ts +8 -0
- package/dist/commands/pkg-list.d.ts.map +1 -0
- package/dist/commands/pkg-list.js +70 -0
- package/dist/commands/pkg-list.js.map +1 -0
- package/dist/commands/pkg-remove.d.ts +10 -0
- package/dist/commands/pkg-remove.d.ts.map +1 -0
- package/dist/commands/pkg-remove.js +53 -0
- package/dist/commands/pkg-remove.js.map +1 -0
- package/dist/commands/pkg-update.d.ts +9 -0
- package/dist/commands/pkg-update.d.ts.map +1 -0
- package/dist/commands/pkg-update.js +124 -0
- package/dist/commands/pkg-update.js.map +1 -0
- package/dist/commands/team-sync.d.ts +14 -0
- package/dist/commands/team-sync.d.ts.map +1 -0
- package/dist/commands/team-sync.js +422 -0
- package/dist/commands/team-sync.js.map +1 -0
- package/dist/commands/whoami.d.ts +6 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +29 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +33 -13
- package/dist/index.js.map +1 -1
- package/dist/strategies/merge.js +2 -2
- package/dist/strategies/merge.js.map +1 -1
- package/dist/utils/auth.d.ts +27 -0
- package/dist/utils/auth.d.ts.map +1 -0
- package/dist/utils/auth.js +155 -0
- package/dist/utils/auth.js.map +1 -0
- package/dist/utils/cognito-session.d.ts +35 -0
- package/dist/utils/cognito-session.d.ts.map +1 -0
- package/dist/utils/cognito-session.js +72 -0
- package/dist/utils/cognito-session.js.map +1 -0
- package/dist/utils/git.js +1 -1
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/hq-root.d.ts +10 -0
- package/dist/utils/hq-root.d.ts.map +1 -0
- package/dist/utils/hq-root.js +22 -0
- package/dist/utils/hq-root.js.map +1 -0
- package/dist/utils/integrity.d.ts +14 -0
- package/dist/utils/integrity.d.ts.map +1 -0
- package/dist/utils/integrity.js +40 -0
- package/dist/utils/integrity.js.map +1 -0
- package/dist/utils/manifest.d.ts.map +1 -1
- package/dist/utils/manifest.js +2 -5
- package/dist/utils/manifest.js.map +1 -1
- package/dist/utils/registry-client.d.ts +65 -0
- package/dist/utils/registry-client.d.ts.map +1 -0
- package/dist/utils/registry-client.js +102 -0
- package/dist/utils/registry-client.js.map +1 -0
- package/dist/utils/registry.d.ts +33 -0
- package/dist/utils/registry.d.ts.map +1 -0
- package/dist/utils/registry.js +58 -0
- package/dist/utils/registry.js.map +1 -0
- package/dist/utils/token-store.d.ts +28 -0
- package/dist/utils/token-store.d.ts.map +1 -0
- package/dist/utils/token-store.js +68 -0
- package/dist/utils/token-store.js.map +1 -0
- package/package.json +35 -45
- package/src/bin/hq-auth-refresh.ts +32 -0
- package/src/commands/add.ts +74 -0
- package/src/commands/auth.ts +153 -0
- package/src/commands/cloud.ts +125 -0
- package/src/commands/list.ts +66 -0
- package/src/commands/login.ts +29 -0
- package/src/commands/logout.ts +25 -0
- package/src/commands/pkg-install.ts +181 -0
- package/src/commands/pkg-list.ts +98 -0
- package/src/commands/pkg-remove.ts +71 -0
- package/src/commands/pkg-update.ts +189 -0
- package/src/commands/sync.ts +149 -0
- package/src/commands/team-sync.ts +629 -0
- package/src/commands/update.ts +71 -0
- package/src/commands/whoami.ts +38 -0
- package/src/index.ts +72 -0
- package/src/schemas/hq-package.schema.json +137 -0
- package/src/strategies/link.ts +62 -0
- package/src/strategies/merge.ts +142 -0
- package/src/types.ts +47 -0
- package/src/utils/auth.ts +193 -0
- package/src/utils/cognito-session.ts +94 -0
- package/src/utils/git.ts +74 -0
- package/src/utils/hq-root.ts +27 -0
- package/src/utils/integrity.ts +54 -0
- package/src/utils/manifest.ts +109 -0
- package/src/utils/registry-client.ts +204 -0
- package/src/utils/registry.ts +89 -0
- package/src/utils/token-store.ts +83 -0
- package/tsconfig.json +8 -0
- package/dist/commands/cloud-setup.d.ts +0 -19
- package/dist/commands/cloud-setup.d.ts.map +0 -1
- package/dist/commands/cloud-setup.js +0 -206
- package/dist/commands/cloud-setup.js.map +0 -1
- package/dist/commands/initial-upload.d.ts +0 -67
- package/dist/commands/initial-upload.d.ts.map +0 -1
- package/dist/commands/initial-upload.js +0 -205
- package/dist/commands/initial-upload.js.map +0 -1
- package/dist/sync-worker.d.ts +0 -11
- package/dist/sync-worker.d.ts.map +0 -1
- package/dist/sync-worker.js +0 -77
- package/dist/sync-worker.js.map +0 -1
- package/dist/utils/api-client.d.ts +0 -26
- package/dist/utils/api-client.d.ts.map +0 -1
- package/dist/utils/api-client.js +0 -87
- package/dist/utils/api-client.js.map +0 -1
- package/dist/utils/credentials.d.ts +0 -44
- package/dist/utils/credentials.d.ts.map +0 -1
- package/dist/utils/credentials.js +0 -101
- package/dist/utils/credentials.js.map +0 -1
- package/dist/utils/sync.d.ts +0 -125
- package/dist/utils/sync.d.ts.map +0 -1
- package/dist/utils/sync.js +0 -291
- 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.5.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
|
+
}
|